ebay-mcp-remote-edition 4.0.0 → 4.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.
package/README.md CHANGED
@@ -626,7 +626,7 @@ Provider behavior:
626
626
  - **Authenticated research session source:** [`src/validation/providers/ebay-research.ts`](src/validation/providers/ebay-research.ts) can source eBay Research authentication from cookie JSON, persisted KV-backed session state, Playwright storage state, or a browser profile directory. Parsed ACTIVE and SOLD tab responses are cached, but automatically invalidated when the authenticated cookie fingerprint changes.
627
627
  - **Social provider:** [`src/validation/providers/social.ts`](src/validation/providers/social.ts) supports phase-1 Twitter/X recent activity, YouTube average-daily-views proxy data exposed through the `youtubeViews24hMillions` field, and Reddit recent post counts. These signals degrade gracefully on provider/API failure and are used as supportive indicators rather than authoritative demand truth.
628
628
  - **Chart provider:** [`src/validation/providers/chart.ts`](src/validation/providers/chart.ts) is still a stub and does not currently contribute chart-based metrics.
629
- - **Previous comeback research provider:** [`src/validation/providers/research.ts`](src/validation/providers/research.ts) is currently a stable placeholder contract for orchestration-side historical research. It returns the future-facing `previousComebackFirstWeekSales` field shape, and it documents `PERPLEXITY_API_KEY` for a later external-research implementation, but it does not yet perform live historical lookup.
629
+ - **Previous comeback research provider:** [`src/validation/providers/research.ts`](src/validation/providers/research.ts) now performs Perplexity-backed historical research when `PERPLEXITY_API_KEY` is configured. It attempts to resolve the prior comeback, normalize previous first-week sales when support exists, assign a `perplexityHistoricalContextScore`, generate concise `historicalContextNotes`, and emit debug diagnostics covering the research query, citations/snippets, resolved prior release, confidence, and score reasoning.
630
630
 
631
631
  Recommendation behavior:
632
632
 
@@ -641,7 +641,7 @@ Known limitations in the current implementation:
641
641
  - If those sold-data variables are missing, validation still runs but sold enrichment degrades to an unavailable/error state rather than providing full historical-sales signals.
642
642
  - The sold-data provider is temporary and intended to be replaced by an internal implementation later.
643
643
  - Authenticated eBay Research requires a valid session source such as `EBAY_RESEARCH_COOKIES_JSON`, a persisted KV session, Playwright storage state, or a browser profile directory; without one, the provider degrades to diagnostic-only output.
644
- - The previous-comeback research provider contract is present, but no live historical inference or Perplexity-backed lookup is implemented yet even when `PERPLEXITY_API_KEY` is configured.
644
+ - The previous-comeback research provider depends on grounded external research and therefore degrades to low-confidence notes with a zero historical score when `PERPLEXITY_API_KEY` is missing, the response cannot be normalized, or reliable evidence is not found.
645
645
  - The browse provider still relies on heuristic query selection and fallback matching.
646
646
  - The YouTube-backed `youtubeViews24hMillions` field is currently an **average daily views proxy**, not a true trailing 24-hour delta.
647
647
  - Social signals are supportive/proxy data only and should not be presented as decisive automated buy logic.
@@ -1,16 +1,497 @@
1
1
  import { getValidationEffectiveContext } from '../effective-context.js';
2
- export async function getPreviousComebackResearchSignals(request) {
3
- await Promise.resolve();
4
- const hasPerplexityKey = (process.env.PERPLEXITY_API_KEY ?? '').trim().length > 0;
2
+ function sanitizeText(value) {
3
+ if (typeof value !== 'string') {
4
+ return null;
5
+ }
6
+ const trimmed = value.trim();
7
+ return trimmed.length > 0 ? trimmed : null;
8
+ }
9
+ function uniqueStrings(values, max = 6) {
10
+ const normalized = values
11
+ .map((value) => sanitizeText(value))
12
+ .filter((value) => value !== null);
13
+ return Array.from(new Set(normalized)).slice(0, max);
14
+ }
15
+ function clamp(value, min, max) {
16
+ return Math.min(max, Math.max(min, value));
17
+ }
18
+ function truncateText(value, maxLength) {
19
+ return value.length <= maxLength ? value : `${value.slice(0, maxLength - 1).trimEnd()}…`;
20
+ }
21
+ function detectResearchSubtype(request) {
5
22
  const effectiveContext = getValidationEffectiveContext(request);
6
- const primaryAlbum = effectiveContext.searchAlbum ?? effectiveContext.searchEvent ?? effectiveContext.searchItem;
23
+ if (effectiveContext.sourceType === 'event') {
24
+ return 'event';
25
+ }
26
+ const combined = [
27
+ request.validation.validationType,
28
+ request.validation.queryContext?.validationScope,
29
+ request.validation.queryContext?.queryScope,
30
+ ]
31
+ .map((value) => sanitizeText(value)?.toLowerCase() ?? '')
32
+ .join(' ');
33
+ if (/\bpob\b|benefit|photocard/.test(combined)) {
34
+ return 'pob';
35
+ }
36
+ if (/pre\s*order|preorder/.test(combined)) {
37
+ return 'preorder';
38
+ }
39
+ if (combined.includes('album')) {
40
+ return 'album';
41
+ }
42
+ return 'general';
43
+ }
44
+ function buildResearchQuery(request) {
45
+ const effectiveContext = getValidationEffectiveContext(request);
46
+ const parts = effectiveContext.sourceType === 'event'
47
+ ? [
48
+ effectiveContext.searchArtist,
49
+ effectiveContext.searchEvent,
50
+ effectiveContext.searchItem,
51
+ effectiveContext.searchLocation,
52
+ ]
53
+ : [effectiveContext.searchArtist, effectiveContext.searchAlbum, effectiveContext.searchItem];
54
+ return uniqueStrings(parts, 8).join(' ');
55
+ }
56
+ function buildPromptFocus(subtype) {
57
+ switch (subtype) {
58
+ case 'pob':
59
+ return [
60
+ 'identify the most likely previous comeback or prior comparable release',
61
+ 'look for collector, POB, or preorder demand commentary from the prior cycle',
62
+ 'note whether prior demand was strong enough to support current collector-focused monitoring',
63
+ ];
64
+ case 'preorder':
65
+ return [
66
+ 'identify the most likely previous comeback or prior comparable release',
67
+ 'look for prior preorder momentum and collector demand signals',
68
+ 'summarize whether earlier release performance supports current preorder confidence',
69
+ ];
70
+ case 'event':
71
+ return [
72
+ 'identify the closest prior comparable release or event context',
73
+ 'summarize prior demand continuity and historical commercial strength',
74
+ 'call out ambiguities so live market data can be weighted appropriately',
75
+ ];
76
+ case 'album':
77
+ return [
78
+ 'identify the immediately previous comeback or album release',
79
+ 'find previous first-week sales where support exists',
80
+ 'summarize prior release momentum and demand continuity for album tracking',
81
+ ];
82
+ default:
83
+ return [
84
+ 'identify the best prior comparable release if one exists',
85
+ 'summarize commercial strength and demand continuity',
86
+ 'flag ambiguity when evidence is weak or mixed',
87
+ ];
88
+ }
89
+ }
90
+ function buildPerplexityPrompt(request) {
91
+ const effectiveContext = getValidationEffectiveContext(request);
92
+ const subtype = detectResearchSubtype(request);
93
+ const query = buildResearchQuery(request);
94
+ const promptFocus = buildPromptFocus(subtype);
95
+ const subjectLabel = effectiveContext.sourceType === 'event'
96
+ ? (effectiveContext.searchEvent ?? effectiveContext.searchItem ?? 'event context')
97
+ : (effectiveContext.searchAlbum ?? effectiveContext.searchItem ?? request.item.name);
98
+ const userPrompt = [
99
+ 'Research the historical commercial context for the following release or validation target.',
100
+ `Primary query: ${query || subjectLabel}`,
101
+ `Artist: ${effectiveContext.searchArtist ?? 'Unknown'}`,
102
+ `Subject: ${subjectLabel}`,
103
+ `Release date: ${effectiveContext.eventDate ?? 'Unknown'}`,
104
+ `Validation type: ${request.validation.validationType}`,
105
+ `Subtype: ${subtype}`,
106
+ `Focus requirements: ${promptFocus.join('; ')}`,
107
+ 'Return JSON only with this exact shape:',
108
+ '{',
109
+ ' "previousAlbumTitle": string | null,',
110
+ ' "previousComebackFirstWeekSales": number | string | null,',
111
+ ' "historicalContextNotes": string,',
112
+ ' "researchConfidence": "Low" | "Medium" | "High",',
113
+ ' "commercialStrengthContext": string | null,',
114
+ ' "collectorDemandContext": string | null,',
115
+ ' "preorderDemandContext": string | null,',
116
+ ' "sourceSnippets": string[],',
117
+ ' "ambiguities": string[],',
118
+ ' "confidenceReason": string | null,',
119
+ ' "scoreReason": string | null,',
120
+ ' "notEnoughEvidence": boolean',
121
+ '}',
122
+ 'Guidance:',
123
+ '- Prefer factual prior-release context and first-week sales if supported.',
124
+ '- For POB or preorder cases, emphasize prior collector/preorder demand momentum.',
125
+ '- If evidence is uncertain, use nulls, concise ambiguity notes, and lower confidence.',
126
+ '- Keep historicalContextNotes concise and operational for Airtable.',
127
+ ].join('\n');
128
+ return { query, promptFocus, userPrompt };
129
+ }
130
+ function extractFirstJsonObject(value) {
131
+ const trimmed = value.trim();
132
+ if (trimmed.length === 0) {
133
+ return null;
134
+ }
135
+ const fencedMatch = /```(?:json)?\s*([\s\S]*?)```/i.exec(trimmed);
136
+ const candidate = fencedMatch?.[1]?.trim() ?? trimmed;
137
+ const firstBrace = candidate.indexOf('{');
138
+ if (firstBrace === -1) {
139
+ return null;
140
+ }
141
+ let depth = 0;
142
+ let inString = false;
143
+ let isEscaped = false;
144
+ for (let index = firstBrace; index < candidate.length; index += 1) {
145
+ const character = candidate[index];
146
+ if (inString) {
147
+ if (isEscaped) {
148
+ isEscaped = false;
149
+ }
150
+ else if (character === '\\') {
151
+ isEscaped = true;
152
+ }
153
+ else if (character === '"') {
154
+ inString = false;
155
+ }
156
+ continue;
157
+ }
158
+ if (character === '"') {
159
+ inString = true;
160
+ continue;
161
+ }
162
+ if (character === '{') {
163
+ depth += 1;
164
+ }
165
+ else if (character === '}') {
166
+ depth -= 1;
167
+ if (depth === 0) {
168
+ return candidate.slice(firstBrace, index + 1);
169
+ }
170
+ }
171
+ }
172
+ return null;
173
+ }
174
+ function parseResearchResponse(rawContent) {
175
+ const jsonObject = extractFirstJsonObject(rawContent);
176
+ if (jsonObject === null) {
177
+ return null;
178
+ }
179
+ try {
180
+ return JSON.parse(jsonObject);
181
+ }
182
+ catch {
183
+ return null;
184
+ }
185
+ }
186
+ function parseNormalizedSalesValue(amount, unit) {
187
+ const numericPortion = Number(amount);
188
+ if (!Number.isFinite(numericPortion) || numericPortion <= 0) {
189
+ return null;
190
+ }
191
+ const normalizedUnit = unit?.toLowerCase() ?? null;
192
+ const multiplier = normalizedUnit === 'm' || normalizedUnit === 'million'
193
+ ? 1_000_000
194
+ : normalizedUnit === 'k' || normalizedUnit === 'thousand'
195
+ ? 1_000
196
+ : 1;
197
+ return Math.round(numericPortion * multiplier);
198
+ }
199
+ function parseSalesFigure(value) {
200
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
201
+ return Math.round(value);
202
+ }
203
+ if (typeof value !== 'string') {
204
+ return null;
205
+ }
206
+ const normalized = value.trim().toLowerCase().replace(/,/g, '');
207
+ if (normalized.length === 0) {
208
+ return null;
209
+ }
210
+ if (/^\d+(?:\.\d+)?$/.test(normalized)) {
211
+ return parseNormalizedSalesValue(normalized);
212
+ }
213
+ const directSalesLike = /^(?:~|about|approximately|approx\.?|around|over|under|nearly|more than|at least)?\s*(\d+(?:\.\d+)?)\s*(m|million|k|thousand)?\s*(copies|albums|units|sales|sold)?\s*$/i.exec(normalized);
214
+ if (directSalesLike !== null && (directSalesLike[2] || directSalesLike[3])) {
215
+ return parseNormalizedSalesValue(directSalesLike[1], directSalesLike[2]);
216
+ }
217
+ const numericTokens = Array.from(normalized.matchAll(/\d+(?:\.\d+)?/g)).length;
218
+ if (numericTokens !== 1) {
219
+ return null;
220
+ }
221
+ const contextualSalesLike = /(?:first[-\s]?week(?:\s+\w+){0,3}\s+sales|first[-\s]?week|sales|sold|copies|albums|units)[^\d]{0,24}(\d+(?:\.\d+)?)\s*(m|million|k|thousand)?\b/i.exec(normalized);
222
+ if (contextualSalesLike === null) {
223
+ return null;
224
+ }
225
+ return parseNormalizedSalesValue(contextualSalesLike[1], contextualSalesLike[2]);
226
+ }
227
+ function normalizeConfidence(value, hasSubstantiveEvidence) {
228
+ if (!hasSubstantiveEvidence) {
229
+ return 'Low';
230
+ }
231
+ if (typeof value === 'string') {
232
+ const normalized = value.trim().toLowerCase();
233
+ if (normalized === 'high') {
234
+ return 'High';
235
+ }
236
+ if (normalized === 'medium') {
237
+ return 'Medium';
238
+ }
239
+ if (normalized === 'low') {
240
+ return 'Low';
241
+ }
242
+ }
243
+ return 'Medium';
244
+ }
245
+ function buildHistoricalNotes(parsed, subtype) {
246
+ const explicitNotes = sanitizeText(parsed.historicalContextNotes);
247
+ if (explicitNotes !== null) {
248
+ return truncateText(explicitNotes, 320);
249
+ }
250
+ const parts = [
251
+ sanitizeText(parsed.previousAlbumTitle)
252
+ ? `Previous release identified as ${sanitizeText(parsed.previousAlbumTitle)}.`
253
+ : null,
254
+ parseSalesFigure(parsed.previousComebackFirstWeekSales)
255
+ ? `Reported first-week sales were approximately ${parseSalesFigure(parsed.previousComebackFirstWeekSales)}.`
256
+ : null,
257
+ sanitizeText(parsed.commercialStrengthContext),
258
+ subtype === 'pob' || subtype === 'preorder'
259
+ ? (sanitizeText(parsed.preorderDemandContext) ?? sanitizeText(parsed.collectorDemandContext))
260
+ : sanitizeText(parsed.collectorDemandContext),
261
+ sanitizeText(parsed.ambiguities?.[0]),
262
+ ];
263
+ const joined = uniqueStrings(parts, 5).join(' ');
264
+ return joined.length > 0
265
+ ? truncateText(joined, 320)
266
+ : 'Limited prior-market evidence found. Context weak; rely more on live market signals.';
267
+ }
268
+ function computeHistoricalContextScore(input) {
269
+ const hasSubstantiveEvidence = input.previousAlbumTitle !== null ||
270
+ input.previousComebackFirstWeekSales !== null ||
271
+ input.demandContextPresent;
272
+ if (!hasSubstantiveEvidence) {
273
+ return 0;
274
+ }
275
+ if (input.notEnoughEvidence &&
276
+ input.previousAlbumTitle === null &&
277
+ input.previousComebackFirstWeekSales === null &&
278
+ !input.demandContextPresent) {
279
+ return 0;
280
+ }
281
+ let score = 0;
282
+ if (input.previousAlbumTitle !== null) {
283
+ score += 6;
284
+ }
285
+ if (input.previousComebackFirstWeekSales !== null) {
286
+ score += 6;
287
+ }
288
+ if (input.demandContextPresent) {
289
+ score += 4;
290
+ }
291
+ if (input.snippetCount >= 2) {
292
+ score += 2;
293
+ }
294
+ if (input.snippetCount >= 3) {
295
+ score += 2;
296
+ }
297
+ if (score === 0) {
298
+ return 0;
299
+ }
300
+ if (input.confidence === 'High') {
301
+ return clamp(Math.max(score, 15), 15, 20);
302
+ }
303
+ if (input.confidence === 'Medium') {
304
+ return clamp(Math.max(score, 8), 8, 14);
305
+ }
306
+ return clamp(Math.max(score, 1), 1, 7);
307
+ }
308
+ function buildScoreAssignmentReason(input) {
309
+ const factors = [
310
+ input.previousAlbumTitle !== null ? 'resolved prior release' : null,
311
+ input.previousComebackFirstWeekSales !== null ? 'supported first-week sales' : null,
312
+ input.demandContextPresent ? 'collector/preorder demand context' : null,
313
+ input.snippetCount > 0
314
+ ? `${input.snippetCount} supporting snippet${input.snippetCount === 1 ? '' : 's'}`
315
+ : null,
316
+ ].filter((value) => value !== null);
317
+ const defaultReason = factors.length > 0
318
+ ? `${input.confidence} confidence based on ${factors.join(', ')}; assigned normalized historical score ${input.score}.`
319
+ : `No reliable historical evidence was normalized, so the historical score remained ${input.score}.`;
320
+ const parsedReason = sanitizeText(input.parsedScoreReason);
321
+ return parsedReason !== null ? truncateText(parsedReason, 240) : defaultReason;
322
+ }
323
+ function buildFallbackResearchSignals(input) {
324
+ const confidence = input.confidence ?? 'Low';
325
+ const historicalContextNotes = truncateText(input.notes, 320);
7
326
  return {
8
327
  previousAlbumTitle: null,
9
328
  previousComebackFirstWeekSales: null,
10
- confidence: 'Low',
11
- notes: hasPerplexityKey
12
- ? `Research provider contract is ready, but historical comeback lookup for ${primaryAlbum ?? 'this release'} is not implemented yet.`
13
- : 'Research provider contract is ready, but PERPLEXITY_API_KEY is not configured and historical comeback lookup is not implemented yet.',
329
+ perplexityHistoricalContextScore: 0,
330
+ historicalContextNotes,
331
+ confidence,
332
+ notes: historicalContextNotes,
14
333
  sources: [],
334
+ debug: {
335
+ providerStatus: input.providerStatus,
336
+ parseStatus: input.parseStatus,
337
+ query: input.query ?? null,
338
+ promptFocus: input.promptFocus ?? [],
339
+ citations: [],
340
+ sourceSnippets: [],
341
+ resolvedPriorRelease: null,
342
+ extractedConfidence: null,
343
+ computedConfidence: confidence,
344
+ confidenceReason: null,
345
+ scoreAssignmentReason: input.providerStatus === 'error'
346
+ ? 'Historical research request failed, so the provider returned a zero historical score.'
347
+ : 'Historical research did not yield structured evidence, so the provider returned a zero historical score.',
348
+ rawResponseText: input.rawResponseText ?? null,
349
+ errorMessage: input.errorMessage ?? null,
350
+ },
15
351
  };
16
352
  }
353
+ export async function getPreviousComebackResearchSignals(request) {
354
+ const hasPerplexityKey = (process.env.PERPLEXITY_API_KEY ?? '').trim().length > 0;
355
+ const effectiveContext = getValidationEffectiveContext(request);
356
+ const primaryAlbum = effectiveContext.searchAlbum ?? effectiveContext.searchEvent ?? effectiveContext.searchItem;
357
+ const { query, promptFocus, userPrompt } = buildPerplexityPrompt(request);
358
+ if (!hasPerplexityKey) {
359
+ return buildFallbackResearchSignals({
360
+ notes: `PERPLEXITY_API_KEY is not configured. Historical context for ${primaryAlbum ?? 'this release'} remains unverified; rely more on live market signals.`,
361
+ providerStatus: 'unconfigured',
362
+ parseStatus: 'unconfigured',
363
+ query,
364
+ promptFocus,
365
+ });
366
+ }
367
+ if (query.length === 0) {
368
+ return buildFallbackResearchSignals({
369
+ notes: 'Historical research query could not be derived from the current validation context.',
370
+ providerStatus: 'no_evidence',
371
+ parseStatus: 'fallback',
372
+ query: null,
373
+ promptFocus,
374
+ });
375
+ }
376
+ const controller = new AbortController();
377
+ const timeout = setTimeout(() => controller.abort(), 30_000);
378
+ try {
379
+ const response = await fetch('https://api.perplexity.ai/chat/completions', {
380
+ method: 'POST',
381
+ headers: {
382
+ 'Content-Type': 'application/json',
383
+ Authorization: `Bearer ${process.env.PERPLEXITY_API_KEY ?? ''}`,
384
+ },
385
+ body: JSON.stringify({
386
+ model: 'sonar',
387
+ temperature: 0.1,
388
+ max_tokens: 900,
389
+ messages: [
390
+ {
391
+ role: 'system',
392
+ content: 'You are a historical music market research assistant. Use grounded web research, stay cautious with ambiguous evidence, and return JSON only.',
393
+ },
394
+ {
395
+ role: 'user',
396
+ content: userPrompt,
397
+ },
398
+ ],
399
+ }),
400
+ signal: controller.signal,
401
+ });
402
+ const responseText = await response.text();
403
+ if (!response.ok) {
404
+ return buildFallbackResearchSignals({
405
+ notes: `Perplexity historical research request failed for ${primaryAlbum ?? 'this release'}.`,
406
+ providerStatus: 'error',
407
+ parseStatus: 'error',
408
+ query,
409
+ promptFocus,
410
+ rawResponseText: responseText,
411
+ errorMessage: `HTTP ${response.status}`,
412
+ });
413
+ }
414
+ const parsedResponse = JSON.parse(responseText);
415
+ const rawContent = parsedResponse.choices?.[0]?.message?.content?.trim() ?? '';
416
+ const parsed = parseResearchResponse(rawContent);
417
+ if (parsed === null) {
418
+ return buildFallbackResearchSignals({
419
+ notes: `Perplexity returned an unstructured historical response for ${primaryAlbum ?? 'this release'}, so live market signals should carry more weight.`,
420
+ providerStatus: 'error',
421
+ parseStatus: 'error',
422
+ query,
423
+ promptFocus,
424
+ rawResponseText: rawContent,
425
+ errorMessage: 'Unable to normalize Perplexity response into structured JSON.',
426
+ });
427
+ }
428
+ const previousAlbumTitle = sanitizeText(parsed.previousAlbumTitle);
429
+ const previousComebackFirstWeekSales = parseSalesFigure(parsed.previousComebackFirstWeekSales);
430
+ const demandContextPresent = sanitizeText(parsed.commercialStrengthContext) !== null ||
431
+ sanitizeText(parsed.collectorDemandContext) !== null ||
432
+ sanitizeText(parsed.preorderDemandContext) !== null;
433
+ const sourceSnippets = uniqueStrings(parsed.sourceSnippets ?? [], 4);
434
+ const citations = uniqueStrings(parsedResponse.citations ?? [], 6);
435
+ const hasSubstantiveEvidence = previousAlbumTitle !== null ||
436
+ previousComebackFirstWeekSales !== null ||
437
+ demandContextPresent;
438
+ const confidence = normalizeConfidence(parsed.researchConfidence, hasSubstantiveEvidence);
439
+ const score = computeHistoricalContextScore({
440
+ confidence,
441
+ previousAlbumTitle,
442
+ previousComebackFirstWeekSales,
443
+ demandContextPresent,
444
+ snippetCount: sourceSnippets.length + Math.min(citations.length, 2),
445
+ notEnoughEvidence: parsed.notEnoughEvidence === true,
446
+ });
447
+ const historicalContextNotes = buildHistoricalNotes(parsed, detectResearchSubtype(request));
448
+ const scoreAssignmentReason = buildScoreAssignmentReason({
449
+ confidence,
450
+ previousAlbumTitle,
451
+ previousComebackFirstWeekSales,
452
+ demandContextPresent,
453
+ snippetCount: sourceSnippets.length + citations.length,
454
+ score,
455
+ parsedScoreReason: sanitizeText(parsed.scoreReason),
456
+ });
457
+ return {
458
+ previousAlbumTitle,
459
+ previousComebackFirstWeekSales,
460
+ perplexityHistoricalContextScore: score,
461
+ historicalContextNotes,
462
+ confidence,
463
+ notes: historicalContextNotes,
464
+ sources: citations,
465
+ debug: {
466
+ providerStatus: hasSubstantiveEvidence ? 'ok' : 'no_evidence',
467
+ parseStatus: 'ok',
468
+ query,
469
+ promptFocus,
470
+ citations,
471
+ sourceSnippets,
472
+ resolvedPriorRelease: previousAlbumTitle,
473
+ extractedConfidence: sanitizeText(parsed.researchConfidence)
474
+ ? normalizeConfidence(parsed.researchConfidence, hasSubstantiveEvidence)
475
+ : null,
476
+ computedConfidence: confidence,
477
+ confidenceReason: sanitizeText(parsed.confidenceReason),
478
+ scoreAssignmentReason,
479
+ rawResponseText: rawContent,
480
+ errorMessage: null,
481
+ },
482
+ };
483
+ }
484
+ catch (error) {
485
+ return buildFallbackResearchSignals({
486
+ notes: `Historical research lookup failed for ${primaryAlbum ?? 'this release'}. Context remains weak until live market data improves.`,
487
+ providerStatus: 'error',
488
+ parseStatus: 'error',
489
+ query,
490
+ promptFocus,
491
+ errorMessage: error instanceof Error ? error.message : String(error),
492
+ });
493
+ }
494
+ finally {
495
+ clearTimeout(timeout);
496
+ }
497
+ }
@@ -35,8 +35,15 @@ function hasUsableTrackingQuery(effectiveContext) {
35
35
  }
36
36
  return buildFallbackTrackingQuery(effectiveContext) !== null;
37
37
  }
38
- function resolvePreferredSoldValue(soldValue, researchValue) {
39
- return soldValue ?? researchValue;
38
+ function resolvePreferredSoldValue(researchValue, soldValue) {
39
+ return researchValue ?? soldValue;
40
+ }
41
+ function hasResearchSoldEvidence(signals) {
42
+ return (signals.terapeak.provider === 'ebay_research_ui' &&
43
+ (signals.terapeak.researchSoldPriceUsd !== null ||
44
+ signals.terapeak.soldListingsCount !== null ||
45
+ signals.terapeak.recentSoldCount7d !== null ||
46
+ Object.values(signals.terapeak.soldVelocity).some((value) => value !== null)));
40
47
  }
41
48
  export function buildValidationRecommendation(request, signals) {
42
49
  const dDay = request.validation.dDay;
@@ -73,9 +80,9 @@ export function buildValidationRecommendation(request, signals) {
73
80
  request.item.name ??
74
81
  'release');
75
82
  const mergedSoldVelocity = {
76
- day1Sold: resolvePreferredSoldValue(signals.sold.soldVelocity.day1Sold, signals.terapeak.soldVelocity.day1Sold),
77
- day2Sold: resolvePreferredSoldValue(signals.sold.soldVelocity.day2Sold, signals.terapeak.soldVelocity.day2Sold),
78
- day3Sold: resolvePreferredSoldValue(signals.sold.soldVelocity.day3Sold, signals.terapeak.soldVelocity.day3Sold),
83
+ day1Sold: resolvePreferredSoldValue(signals.terapeak.soldVelocity.day1Sold, signals.sold.soldVelocity.day1Sold),
84
+ day2Sold: resolvePreferredSoldValue(signals.terapeak.soldVelocity.day2Sold, signals.sold.soldVelocity.day2Sold),
85
+ day3Sold: resolvePreferredSoldValue(signals.terapeak.soldVelocity.day3Sold, signals.sold.soldVelocity.day3Sold),
79
86
  };
80
87
  const recentSoldCount = [
81
88
  mergedSoldVelocity.day1Sold,
@@ -85,17 +92,19 @@ export function buildValidationRecommendation(request, signals) {
85
92
  const hasSoldProviderEvidence = signals.sold.soldMedianPriceUsd !== null ||
86
93
  signals.sold.soldResultsCount !== null ||
87
94
  Object.values(signals.sold.soldVelocity).some((value) => value !== null);
88
- const hasResearchSoldEvidence = signals.terapeak.provider === 'ebay_research_ui' &&
89
- (signals.terapeak.soldListingsCount !== null ||
90
- signals.terapeak.recentSoldCount7d !== null ||
91
- Object.values(signals.terapeak.soldVelocity).some((value) => value !== null));
92
- const effectiveSoldComparablePrice = signals.sold.soldMedianPriceUsd ??
93
- (hasResearchSoldEvidence ? signals.terapeak.researchSoldPriceUsd : null);
94
- const effectiveSoldConfidence = hasSoldProviderEvidence
95
- ? signals.sold.confidence
96
- : hasResearchSoldEvidence
97
- ? signals.terapeak.confidence
95
+ const researchSoldEvidence = hasResearchSoldEvidence(signals);
96
+ const effectiveSoldComparablePrice = (researchSoldEvidence ? signals.terapeak.researchSoldPriceUsd : null) ??
97
+ signals.sold.soldMedianPriceUsd;
98
+ const effectiveSoldConfidence = researchSoldEvidence
99
+ ? signals.terapeak.confidence
100
+ : hasSoldProviderEvidence
101
+ ? signals.sold.confidence
98
102
  : 'Low';
103
+ const hasUsableHistoricalResearch = signals.research.debug?.providerStatus !== undefined
104
+ ? signals.research.debug.providerStatus === 'ok'
105
+ : signals.research.previousAlbumTitle !== null ||
106
+ signals.research.previousComebackFirstWeekSales !== null ||
107
+ signals.research.perplexityHistoricalContextScore > 0;
99
108
  let latestAiRecommendation = 'Continue watching until stronger market signal appears.';
100
109
  let latestAiConfidence = 'Medium';
101
110
  let monitoringNotes = `Baseline recommendation generated from current ${signals.effectiveContext.mode} validation state for ${subjectLabel}.`;
@@ -157,9 +166,12 @@ export function buildValidationRecommendation(request, signals) {
157
166
  latestAiConfidence = 'Medium';
158
167
  }
159
168
  }
160
- if (signals.research.previousComebackFirstWeekSales !== null) {
169
+ if (hasUsableHistoricalResearch && signals.research.previousComebackFirstWeekSales !== null) {
161
170
  monitoringNotes += ` Previous comeback first-week sales reference: ${signals.research.previousComebackFirstWeekSales}.`;
162
171
  }
172
+ if (hasUsableHistoricalResearch && signals.research.historicalContextNotes.length > 0) {
173
+ monitoringNotes += ` Historical context (${signals.research.confidence}, score ${signals.research.perplexityHistoricalContextScore}/20): ${signals.research.historicalContextNotes}`;
174
+ }
163
175
  return {
164
176
  buyDecision: request.validation.buyDecision,
165
177
  automationStatus: shouldAutoTrack ? 'Watching' : 'Paused',
@@ -49,13 +49,69 @@ function getFieldPresence(fields) {
49
49
  function getWriteSource(value, source) {
50
50
  return isMeaningfulWriteValue(value) ? source : 'none';
51
51
  }
52
- function resolvePreferredSoldMetric(soldValue, terapeakValue, ebayValue) {
53
- if (soldValue !== null) {
54
- return { value: soldValue, source: 'sold' };
55
- }
52
+ function hasSoldVelocityEvidence(soldVelocity) {
53
+ return Object.values(soldVelocity).some((value) => value !== null);
54
+ }
55
+ function hasPrimaryResearchSoldSignals(terapeak) {
56
+ return (terapeak.provider === 'ebay_research_ui' &&
57
+ (terapeak.researchSoldPriceUsd !== null ||
58
+ terapeak.soldListingsCount !== null ||
59
+ terapeak.recentSoldCount7d !== null ||
60
+ hasSoldVelocityEvidence(terapeak.soldVelocity)));
61
+ }
62
+ function createSkippedSoldSignals() {
63
+ return {
64
+ provider: 'third_party_sold_api',
65
+ confidence: 'Low',
66
+ soldResultsCount: null,
67
+ soldAveragePriceUsd: null,
68
+ soldMedianPriceUsd: null,
69
+ soldMinPriceUsd: null,
70
+ soldMaxPriceUsd: null,
71
+ soldItemsSample: [],
72
+ soldVelocity: {
73
+ day1Sold: null,
74
+ day2Sold: null,
75
+ day3Sold: null,
76
+ day4Sold: null,
77
+ day5Sold: null,
78
+ daysTracked: null,
79
+ },
80
+ recentSoldCount7d: null,
81
+ soldBucketDebug: {
82
+ status: 'skipped',
83
+ notes: [
84
+ 'legacy sold fallback was skipped because first-party research sold signals were sufficient',
85
+ ],
86
+ totalItemsExamined: 0,
87
+ withSoldAt: 0,
88
+ missingSoldAt: 0,
89
+ dateParseFailures: 0,
90
+ futureDated: 0,
91
+ bucketedItems: 0,
92
+ },
93
+ query: null,
94
+ queryCandidates: [],
95
+ queryDiagnostics: [],
96
+ selectedQuery: undefined,
97
+ selectedQueryTier: null,
98
+ selectedQueryFamily: null,
99
+ broadAlbumQuery: null,
100
+ subtypeSpecificQuery: null,
101
+ querySelectionReason: 'Legacy sold fallback was skipped because first-party research sold signals were sufficient.',
102
+ responseUrl: null,
103
+ status: 'skipped',
104
+ errorMessage: undefined,
105
+ queryResolution: undefined,
106
+ };
107
+ }
108
+ function resolvePreferredSoldMetric(terapeakValue, soldValue, ebayValue) {
56
109
  if (terapeakValue !== null) {
57
110
  return { value: terapeakValue, source: 'terapeak' };
58
111
  }
112
+ if (soldValue !== null) {
113
+ return { value: soldValue, source: 'sold' };
114
+ }
59
115
  if (ebayValue !== null) {
60
116
  return { value: ebayValue, source: 'ebay' };
61
117
  }
@@ -105,6 +161,9 @@ function buildProviderDebug(request, ebay, sold, terapeak, social, chart, resear
105
161
  const researchFields = getFieldPresence({
106
162
  previousAlbumTitle: research.previousAlbumTitle,
107
163
  previousComebackFirstWeekSales: research.previousComebackFirstWeekSales,
164
+ perplexityHistoricalContextScore: research.perplexityHistoricalContextScore,
165
+ historicalContextNotes: research.historicalContextNotes,
166
+ researchConfidence: research.confidence,
108
167
  });
109
168
  const ebayStatus = (ebay.queryCandidates?.length ?? 0) === 0
110
169
  ? 'unavailable'
@@ -117,9 +176,15 @@ function buildProviderDebug(request, ebay, sold, terapeak, social, chart, resear
117
176
  : terapeakFields.contributed.length > 0
118
177
  ? 'ok'
119
178
  : 'partial';
120
- const researchStatus = research.previousComebackFirstWeekSales !== null || research.previousAlbumTitle !== null
121
- ? 'ok'
122
- : 'stub';
179
+ const researchStatus = research.debug?.providerStatus === 'unconfigured'
180
+ ? 'unavailable'
181
+ : research.debug?.providerStatus === 'error'
182
+ ? 'partial'
183
+ : research.previousComebackFirstWeekSales !== null ||
184
+ research.previousAlbumTitle !== null ||
185
+ research.perplexityHistoricalContextScore > 0
186
+ ? 'ok'
187
+ : 'partial';
123
188
  return {
124
189
  ebay: {
125
190
  status: ebayStatus,
@@ -225,10 +290,23 @@ function buildProviderDebug(request, ebay, sold, terapeak, social, chart, resear
225
290
  confidence: research.confidence.toLowerCase(),
226
291
  previousAlbumTitle: research.previousAlbumTitle,
227
292
  previousComebackFirstWeekSales: research.previousComebackFirstWeekSales,
293
+ perplexityHistoricalContextScore: research.perplexityHistoricalContextScore,
294
+ historicalContextNotes: research.historicalContextNotes,
228
295
  contributedFields: researchFields.contributed,
229
296
  omittedFields: researchFields.omitted,
230
297
  notes: research.notes,
231
298
  sources: research.sources ?? [],
299
+ query: research.debug?.query ?? null,
300
+ promptFocus: research.debug?.promptFocus ?? [],
301
+ sourceSnippets: research.debug?.sourceSnippets ?? [],
302
+ resolvedPriorRelease: research.debug?.resolvedPriorRelease ?? research.previousAlbumTitle,
303
+ extractedConfidence: research.debug?.extractedConfidence ?? null,
304
+ computedConfidence: research.debug?.computedConfidence ?? research.confidence,
305
+ confidenceReason: research.debug?.confidenceReason ?? null,
306
+ scoreAssignmentReason: research.debug?.scoreAssignmentReason ?? null,
307
+ providerStatus: research.debug?.providerStatus ?? null,
308
+ parseStatus: research.debug?.parseStatus ?? null,
309
+ errorMessage: research.debug?.errorMessage ?? null,
232
310
  },
233
311
  };
234
312
  }
@@ -254,8 +332,11 @@ export async function runValidation(api, input) {
254
332
  effectiveContext,
255
333
  };
256
334
  const ebay = await getEbayValidationSignals(api, effectiveRequest);
257
- const sold = await getEbaySoldValidationSignals(effectiveRequest);
258
335
  const terapeak = await getTerapeakValidationSignals(api, effectiveRequest);
336
+ const primaryResearchSoldSignalsAvailable = hasPrimaryResearchSoldSignals(terapeak);
337
+ const sold = primaryResearchSoldSignalsAvailable
338
+ ? createSkippedSoldSignals()
339
+ : await getEbaySoldValidationSignals(effectiveRequest);
259
340
  const social = await getSocialValidationSignals(effectiveRequest);
260
341
  const chart = getChartValidationSignals(effectiveRequest);
261
342
  const research = await getPreviousComebackResearchSignals(effectiveRequest);
@@ -264,12 +345,12 @@ export async function runValidation(api, input) {
264
345
  const marketPriceUsd = terapeak.marketPriceUsd ?? sold.soldMedianPriceUsd ?? ebay.marketPriceUsd;
265
346
  const mergedAvgShippingCostUsd = terapeak.avgShippingCostUsd ?? ebay.avgShippingCostUsd;
266
347
  const mergedCompetitionLevel = terapeak.competitionLevel ?? ebay.competitionLevel;
267
- const day1Sold = resolvePreferredSoldMetric(sold.soldVelocity.day1Sold, terapeak.soldVelocity.day1Sold, ebay.soldVelocity.day1Sold);
268
- const day2Sold = resolvePreferredSoldMetric(sold.soldVelocity.day2Sold, terapeak.soldVelocity.day2Sold, ebay.soldVelocity.day2Sold);
269
- const day3Sold = resolvePreferredSoldMetric(sold.soldVelocity.day3Sold, terapeak.soldVelocity.day3Sold, ebay.soldVelocity.day3Sold);
270
- const day4Sold = resolvePreferredSoldMetric(sold.soldVelocity.day4Sold, terapeak.soldVelocity.day4Sold, ebay.soldVelocity.day4Sold);
271
- const day5Sold = resolvePreferredSoldMetric(sold.soldVelocity.day5Sold, terapeak.soldVelocity.day5Sold, ebay.soldVelocity.day5Sold);
272
- const daysTracked = resolvePreferredSoldMetric(sold.soldVelocity.daysTracked, terapeak.soldVelocity.daysTracked, ebay.soldVelocity.daysTracked);
348
+ const day1Sold = resolvePreferredSoldMetric(terapeak.soldVelocity.day1Sold, sold.soldVelocity.day1Sold, ebay.soldVelocity.day1Sold);
349
+ const day2Sold = resolvePreferredSoldMetric(terapeak.soldVelocity.day2Sold, sold.soldVelocity.day2Sold, ebay.soldVelocity.day2Sold);
350
+ const day3Sold = resolvePreferredSoldMetric(terapeak.soldVelocity.day3Sold, sold.soldVelocity.day3Sold, ebay.soldVelocity.day3Sold);
351
+ const day4Sold = resolvePreferredSoldMetric(terapeak.soldVelocity.day4Sold, sold.soldVelocity.day4Sold, ebay.soldVelocity.day4Sold);
352
+ const day5Sold = resolvePreferredSoldMetric(terapeak.soldVelocity.day5Sold, sold.soldVelocity.day5Sold, ebay.soldVelocity.day5Sold);
353
+ const daysTracked = resolvePreferredSoldMetric(terapeak.soldVelocity.daysTracked, sold.soldVelocity.daysTracked, ebay.soldVelocity.daysTracked);
273
354
  const soldVelocity = {
274
355
  day1Sold: day1Sold.value,
275
356
  day2Sold: day2Sold.value,
@@ -289,6 +370,39 @@ export async function runValidation(api, input) {
289
370
  });
290
371
  const requestQueryResolution = buildProviderQueryResolutionDebug(effectiveRequest, Boolean(ebay.queryResolution?.queryContextUsed));
291
372
  const mergedSignals = { effectiveContext, ebay, sold, terapeak, social, chart, research };
373
+ const activeSource = terapeak.avgWatchersPerListing !== null ||
374
+ terapeak.preOrderListingsCount !== null ||
375
+ terapeak.competitionLevel !== null
376
+ ? 'ebay_research_ui'
377
+ : ebay.preOrderListingsCount !== null ||
378
+ ebay.marketPriceUsd !== null ||
379
+ ebay.competitionLevel !== null
380
+ ? 'ebay_browse'
381
+ : 'none';
382
+ const soldSource = primaryResearchSoldSignalsAvailable
383
+ ? 'ebay_research_ui'
384
+ : sold.soldMedianPriceUsd !== null ||
385
+ sold.soldResultsCount !== null ||
386
+ hasSoldVelocityEvidence(sold.soldVelocity)
387
+ ? 'third_party_sold_api'
388
+ : ebay.marketPriceUsd !== null
389
+ ? 'ebay_browse'
390
+ : 'none';
391
+ const providerResolution = {
392
+ activeSource,
393
+ soldSource,
394
+ soldFallbackUsed: !primaryResearchSoldSignalsAvailable && soldSource === 'third_party_sold_api',
395
+ fallbackReason: primaryResearchSoldSignalsAvailable
396
+ ? null
397
+ : soldSource === 'third_party_sold_api'
398
+ ? 'First-party research sold signals were unavailable or insufficient, so the legacy sold provider was used as automatic fallback.'
399
+ : 'First-party research sold signals were unavailable or insufficient, but no legacy sold fallback data was available.',
400
+ };
401
+ const hasUsableHistoricalResearch = research.debug?.providerStatus !== undefined
402
+ ? research.debug.providerStatus === 'ok'
403
+ : research.previousAlbumTitle !== null ||
404
+ research.previousComebackFirstWeekSales !== null ||
405
+ research.perplexityHistoricalContextScore > 0;
292
406
  const socialWrites = {
293
407
  ...(social.twitterTrending !== null ? { twitterTrending: social.twitterTrending } : {}),
294
408
  ...(social.youtubeViews24hMillions !== null
@@ -307,7 +421,14 @@ export async function runValidation(api, input) {
307
421
  : {}),
308
422
  };
309
423
  const researchWrites = {
310
- ...(research.previousComebackFirstWeekSales !== null
424
+ ...(hasUsableHistoricalResearch
425
+ ? {
426
+ perplexityHistoricalContextScore: research.perplexityHistoricalContextScore,
427
+ historicalContextNotes: research.historicalContextNotes,
428
+ researchConfidence: research.confidence,
429
+ }
430
+ : {}),
431
+ ...(hasUsableHistoricalResearch && research.previousComebackFirstWeekSales !== null
311
432
  ? { previousComebackFirstWeekSales: research.previousComebackFirstWeekSales }
312
433
  : {}),
313
434
  };
@@ -356,7 +477,10 @@ export async function runValidation(api, input) {
356
477
  previousPobSellThroughPct: terapeak.previousPobSellThroughPct !== null
357
478
  ? (terapeak.queryDebug.writeSources?.previousPobSellThroughPct ?? 'terapeak')
358
479
  : 'none',
359
- previousComebackFirstWeekSales: getWriteSource(research.previousComebackFirstWeekSales, 'research'),
480
+ previousComebackFirstWeekSales: getWriteSource(hasUsableHistoricalResearch ? research.previousComebackFirstWeekSales : null, 'research'),
481
+ perplexityHistoricalContextScore: hasUsableHistoricalResearch ? 'research' : 'none',
482
+ historicalContextNotes: hasUsableHistoricalResearch ? 'research' : 'none',
483
+ researchConfidence: hasUsableHistoricalResearch ? 'research' : 'none',
360
484
  };
361
485
  const omittedOptionalWrites = Object.entries(writeResolution)
362
486
  .filter(([, source]) => source === 'none')
@@ -412,6 +536,7 @@ export async function runValidation(api, input) {
412
536
  soldResultsCount: sold.soldResultsCount,
413
537
  omittedOptionalWrites,
414
538
  writeResolution,
539
+ providerResolution,
415
540
  sourceSet: ['ebay', 'sold', 'terapeak', 'social', 'chart', 'research'],
416
541
  providers: buildProviderDebug(effectiveRequest, ebay, sold, terapeak, social, chart, research),
417
542
  queryContextUsed: requestQueryResolution.queryContextUsed,
@@ -111,6 +111,9 @@ export const validationWritesSchema = z.object({
111
111
  previousPobAvgPriceUsd: z.number().nullable().optional(),
112
112
  previousPobSellThroughPct: z.number().nullable().optional(),
113
113
  previousComebackFirstWeekSales: z.number().nullable().optional(),
114
+ perplexityHistoricalContextScore: z.number().optional(),
115
+ historicalContextNotes: z.string().optional(),
116
+ researchConfidence: z.enum(['High', 'Medium', 'Low']).optional(),
114
117
  monitoringNotes: z.string().optional(),
115
118
  lastDataSnapshot: z.string().optional(),
116
119
  latestAiRecommendation: z.string().optional(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ebay-mcp-remote-edition",
3
- "version": "4.0.0",
3
+ "version": "4.1.0",
4
4
  "description": "Remote + Local MCP server for eBay APIs - provides access to eBay developer functionality through MCP (Model Context Protocol)",
5
5
  "type": "module",
6
6
  "main": "build/index.js",