fodda-mcp 1.30.0 → 1.32.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.
@@ -10,15 +10,15 @@ import { z } from 'zod';
10
10
  import { GoogleGenAI } from '@google/genai';
11
11
  import axios from 'axios';
12
12
  import crypto from 'crypto';
13
- import { buildDynamicPromptSections, getDomainGraphIds, getGraphs, getLiveGraphs, buildDisplayName, getRelevantGraphs, getEnabledSkillConfigs, getSkillGraphs, getAnalysts } from './catalogCache.js';
13
+ import { buildDynamicPromptSections, getDomainGraphIds, getGraphs, getLiveGraphs, buildDisplayName, getRelevantGraphs, getRelevantSources, getEnabledSkillConfigs, getSkillGraphs, getAnalysts } from './catalogCache.js';
14
14
  import { renderBrandWidget } from './brandTemplate.js';
15
15
  import { renderSearchWidget } from './searchTemplate.js';
16
16
  import { FODDA_COMPONENT_GUIDE, getShellTemplate } from './widgetShell.js';
17
17
  import { MCP_SERVER_VERSION } from './tools.js';
18
18
  import { buildSystemPrompt, BRAND_INTELLIGENCE_RENDERING_SPEC, FODDA_WIDGET_DESIGN_BRIEF } from './systemPrompt.js';
19
19
  import { computeLifecycle, computeMomentum, isFastMover, enrichEvidence, GRAPH_BADGES, getFoddaTheme, getSupplementalTheme } from './enrichment.js';
20
- import { handleAccessError, handleTrialCreditExhaustion } from './errorHandling.js';
21
- import { chargeQuery } from './pricingCache.js';
20
+ import { handleAccessError, handleTrialCreditExhaustion, classifyAccessError } from './errorHandling.js';
21
+ import { chargeQuery, getToolCostSummary } from './pricingCache.js';
22
22
  import { callOutputSkills, buildSkillInput, discoverSkillTools, executeSkillTool, mapSkillError } from './skillClient.js';
23
23
  import { createSessionTracker, postToSlack } from './sessionTracker.js';
24
24
  import { buildResearcherInstruction } from './agents/fodda-researcher/index.js';
@@ -53,13 +53,13 @@ function buildRenderInstructions(opts) {
53
53
  return instructions;
54
54
  }
55
55
  /**
56
- * Append a low-credit warning to the response data if tokens are running low.
56
+ * Append a low-credit warning to the response data if API calls are running low.
57
57
  * Utilizes new dynamic Stripe links and upsell data provided by the Fodda API.
58
58
  *
59
59
  * Also surfaces upstream X-Usage-* header warnings (approaching-limit, overage-active)
60
60
  * injected by foddaRequest() as _upstream_usage.
61
61
  */
62
- function appendUsageWarning(data, isTrial, userEmail) {
62
+ function appendUsageWarning(data, userEmail) {
63
63
  // ── Upstream header-based warnings (X-Usage-Warning / X-Usage-Percent / X-Usage-Overage-Tokens) ──
64
64
  if (data?._upstream_usage) {
65
65
  const u = data._upstream_usage;
@@ -68,8 +68,8 @@ function appendUsageWarning(data, isTrial, userEmail) {
68
68
  }
69
69
  else if (u.warning === 'overage-active') {
70
70
  data._usage_status = u.overage_tokens
71
- ? `📊 You're in overage — ${u.overage_tokens} additional token(s) used at $0.20/token this billing cycle.`
72
- : `📊 Overage billing is active — additional queries are charged at $0.20/token.`;
71
+ ? `📊 You're in overage — ${u.overage_tokens} additional API call(s) used at $0.50/API call this billing cycle.`
72
+ : `📊 Overage billing is active — additional queries are charged at $0.50/API call.`;
73
73
  }
74
74
  }
75
75
  if (!data?.usage)
@@ -77,7 +77,7 @@ function appendUsageWarning(data, isTrial, userEmail) {
77
77
  const remaining = data.usage.remaining ?? data.usage.credits_remaining;
78
78
  if (remaining === undefined || remaining < 0)
79
79
  return;
80
- const threshold = isTrial ? 10 : 15;
80
+ const threshold = 15;
81
81
  if (remaining >= threshold)
82
82
  return;
83
83
  const noun = remaining === 1 ? 'API call' : 'API calls';
@@ -89,10 +89,7 @@ function appendUsageWarning(data, isTrial, userEmail) {
89
89
  if (userEmail && userEmail.includes('@'))
90
90
  portalParams.set('email', userEmail);
91
91
  const portalUrl = `${API_BASE_URL.replace('api.', 'app.')}/portal?${portalParams.toString()}`;
92
- if (isTrial) {
93
- data._credit_warning = `\u26a0\ufe0f You have ${remaining} ${noun} remaining on your trial. Upgrade to the free Base plan (100 API calls/month) or a paid plan to continue.\n\n\ud83d\ude80 **[Upgrade Now \u2192](${portalUrl})**`;
94
- }
95
- else {
92
+ {
96
93
  let msg = `\u26a0\ufe0f You have ${remaining} ${noun} remaining this month.`;
97
94
  if (upsell) {
98
95
  msg += ` You can get 100 more API calls for $${upsell.price || '50'} right now: ${upsell.link || portalUrl}`;
@@ -172,7 +169,35 @@ function resolveUserId(sessionUserId, toolProvidedUid) {
172
169
  // ---------------------------------------------------------------------------
173
170
  const activeResearchJobs = new Map();
174
171
  const activeSupplementalJobs = new Map();
175
- export async function createServer(apiKey, userId, foddaRequest, waverunnerRequest, storeWidget, getServiceUrl, entryId = '') {
172
+ export async function createServer(apiKey, userId, foddaRequest, waverunnerRequest, storeWidget, getServiceUrl, entryId = '',
173
+ // anonymous SPT session: token (settlement payer) + connect-time cap/prices (pre-run coverage)
174
+ sptCtx) {
175
+ // ── SPT settlement helpers (inert for credit/API-key sessions: sptCtx is undefined) ──
176
+ // Pre-run guard: refuse a task BEFORE spending compute if this payment token can't cover it.
177
+ // Returns an error result to return immediately, or null to proceed.
178
+ const sptGuard = (queryTypeCode) => {
179
+ if (!sptCtx)
180
+ return null;
181
+ const priceUsd = sptCtx.prices[queryTypeCode];
182
+ if (priceUsd != null && sptCtx.maxAmountCents != null && priceUsd * 100 > sptCtx.maxAmountCents) {
183
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'SPT_INSUFFICIENT', required_usd: priceUsd, message: `This task costs $${priceUsd.toFixed(2)}, above this payment token's limit.` }) }] };
184
+ }
185
+ return null;
186
+ };
187
+ // Settlement-as-gate: SPT sessions await the charge and WITHHOLD the result if it fails;
188
+ // credit sessions fire-and-forget (a missed meter only under-bills, never blocks delivery).
189
+ // Returns an error result to return instead of the payload, or null when it's safe to deliver.
190
+ const settleOrWithhold = async (params, label) => {
191
+ if (sptCtx) {
192
+ const r = await chargeQuery({ ...params, foddaRequest, spt: sptCtx.token });
193
+ if (!r.charged) {
194
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'SPT_SETTLEMENT_FAILED', message: r.error || 'Payment could not be completed; result withheld.' }) }] };
195
+ }
196
+ return null;
197
+ }
198
+ chargeQuery({ ...params, foddaRequest }).catch(e => console.error(`[${label}] chargeQuery failed:`, e.message));
199
+ return null;
200
+ };
176
201
  // Fetch account profile for persona-aware framing (best-effort)
177
202
  let accountProfile;
178
203
  let sessionDisabledGraphs = new Set();
@@ -233,10 +258,9 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
233
258
  catch (err) {
234
259
  console.error('[persona] Failed to fetch account profile — proceeding without persona framing');
235
260
  }
236
- // sk_trial_ keys are retired. New individual trial accounts (planCode 13) use
237
- // sk_live_ keys and are detected from API error responses via isIndividualTrial().
238
- // isTrial is false at session init for all current account types.
239
- const isTrial = false;
261
+ // Note: trial accounts are retired and handled entirely server-side by the API
262
+ // (rejected as no-longer-valid, or surfaced reactively via errorHandling.ts).
263
+ // No client-side trial state remains.
240
264
  const sessionTracker = createSessionTracker();
241
265
  // Fire-and-forget: log the user's query text to the Questions table.
242
266
  // Called at tool entry — BEFORE cache-eligible foddaRequest calls —
@@ -263,7 +287,7 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
263
287
  name: 'fodda_mcp',
264
288
  version: MCP_SERVER_VERSION,
265
289
  }, {
266
- instructions: buildSystemPrompt(accountProfile, skillPromptMeta, isTrial, entryId),
290
+ instructions: buildSystemPrompt(accountProfile, skillPromptMeta, entryId),
267
291
  });
268
292
  // Register empty capabilities and handlers to silence directory warnings
269
293
  server.server.registerCapabilities({
@@ -325,18 +349,6 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
325
349
  const data = await foddaRequest('GET', '/v1/graphs', apiKey, userId);
326
350
  const account = data?._account;
327
351
  if (!account) {
328
- if (isTrial) {
329
- return {
330
- content: [{
331
- type: 'text',
332
- text: JSON.stringify({
333
- plan: 'Trial',
334
- note: 'You\'re on a trial — limited API calls across all expert graphs. I can set you up with a Free Base account (100 API calls/month) instantly — just give me your email.',
335
- manage_url: 'https://app.fodda.ai',
336
- }, null, 2)
337
- }]
338
- };
339
- }
340
352
  return {
341
353
  content: [{
342
354
  type: 'text',
@@ -356,7 +368,7 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
356
368
  if (typeof status.api_calls_remaining === 'number' && status.api_calls_remaining < 0) {
357
369
  status.overage_active = true;
358
370
  status.overage_tokens = Math.abs(status.api_calls_remaining);
359
- status.overage_note = `You're ${Math.abs(status.api_calls_remaining)} token(s) over your monthly limit. Overage charges apply at $0.20/token.`;
371
+ status.overage_note = `You're ${Math.abs(status.api_calls_remaining)} API call(s) over your monthly limit. Overage charges apply at $0.50/API call.`;
360
372
  }
361
373
  if (account.tokens_used !== undefined)
362
374
  status.api_calls_used = account.tokens_used;
@@ -389,6 +401,10 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
389
401
  };
390
402
  }
391
403
  status.graphs_url = 'https://app.fodda.ai/graphs';
404
+ // Surface per-tool costs so the agent can explain spend before running queries
405
+ const costs = getToolCostSummary();
406
+ if (costs.length)
407
+ status.query_costs = costs;
392
408
  return {
393
409
  content: [{
394
410
  type: 'text',
@@ -423,7 +439,7 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
423
439
  // Strip _account from list_graphs output (use get_my_account instead)
424
440
  if (data)
425
441
  delete data._account;
426
- if (account && !account.userContext && !isTrial) {
442
+ if (account && !account.userContext) {
427
443
  const nudge = `\n\n---\n⚠️ NO RESEARCH PROFILE SET for this user.\nResponses will be generic until you capture their profile.\nThrough natural conversation, determine:\n- Their role and what they use Fodda for (pitches, ongoing research, client advisory)\n- What kind of evidence they value (commercial data vs. design inspiration)\n- Geographic focus (global, specific regions)\n- How results should be framed (executive brief vs. deep analysis)\nThen call update_user_profile. Write BEHAVIORAL INSTRUCTIONS, not a bio.\nFormat: one sentence of identity, then numbered directives that change how you respond.\nExample: "Agency strategist doing pitches. (1) Lead with landscape orientation. (2) Prioritize commercial evidence. (3) Time-scarce — strongest findings first."\n---`;
428
444
  const jsonText = JSON.stringify(data, null, 2);
429
445
  return { content: [{ type: 'text', text: jsonText + nudge }] };
@@ -453,7 +469,7 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
453
469
  }
454
470
  });
455
471
  // --- search_graph ---
456
- server.tool('search_graph', 'Search expert-curated knowledge graphs for trend clusters, signals, and consumer behavior evidence across retail, beauty, luxury, fashion, sport, consumer electronics, F&B, travel, and 30+ specialist domains. Returns structured trend data with cited evidence chains, source attribution, lifecycle signals (emerging/building/mature/fading), and momentum indicators — not generic web summaries. If graphId is omitted, searches ALL accessible graphs in parallel (recommended default). Use when the query involves market trends, competitor analysis, innovation signals, consumer behavior, cultural shifts, or any topic where curated expert intelligence outperforms web search.', {
472
+ server.tool('search_graph', 'Find trends, signals, and expert insights across 100+ curated knowledge graphs covering retail, beauty, tech, food, travel, sports, and 30+ specialist domains. Returns trend data with cited evidence, source attribution, and lifecycle stage (emerging/building/mature/fading) — not generic web summaries. If graphId is omitted, searches ALL accessible graphs in parallel (recommended default). Use for market trends, competitor analysis, innovation signals, consumer behavior, cultural shifts, or any topic where curated expert intelligence outperforms web search.', {
457
473
  graphId: z.string().optional().describe("Optional graph ID. If omitted, searches ALL accessible graphs. Examples: 'retail', 'tech', 'food', 'travel', 'beauty', 'sports', 'sic', 'pew', 'ce-design', 'ezra-eeman-wayfinder', 'dhl-ecommerce-trends-2026', 'automotive-color-trends', 'alyson-stevens-macro', 'generative-realities', 'pwc/sxsw-2026-key-insights', 'green-house/thrive-report', 'delta/the-connection-index'"),
458
474
  query: z.string().describe('The search query. Location terms are auto-detected and used to filter results geographically.'),
459
475
  userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
@@ -483,6 +499,10 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
483
499
  const results = await Promise.allSettled(graphsToSearch.map(g => foddaRequest('POST', `/v1/graphs/${encodeURIComponent(g.graph_id)}/search`, apiKey, resolveUserId(userId, uid), { ...body, limit: perGraphLimit })));
484
500
  // Merge rows, deduplicate by trendId + near-duplicate name detection
485
501
  const allRows = [];
502
+ // Capture any per-graph rejection caused by credit/quota exhaustion, so an
503
+ // out-of-credits state is surfaced explicitly instead of masquerading as an
504
+ // empty NO_MATCH coverage gap (the fan-out otherwise swallows rejections).
505
+ let creditRejection = null;
486
506
  const seen = new Set();
487
507
  const seenNames = []; // for near-duplicate check
488
508
  const nameTokens = (name) => new Set(name.toLowerCase().replace(/[^a-z0-9\s]/g, '').split(/\s+/).filter(t => t.length > 2));
@@ -496,8 +516,12 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
496
516
  };
497
517
  for (let i = 0; i < results.length; i++) {
498
518
  const r = results[i];
499
- if (r.status !== 'fulfilled')
519
+ if (r.status !== 'fulfilled') {
520
+ if (!creditRejection && classifyAccessError(r.reason) === 'credits') {
521
+ creditRejection = r.reason;
522
+ }
500
523
  continue;
524
+ }
501
525
  const fulfilled = r;
502
526
  const rows = Array.isArray(fulfilled.value) ? fulfilled.value : (fulfilled.value?.rows || []);
503
527
  const graphMeta = relevantGraphs[i];
@@ -619,6 +643,14 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
619
643
  })();
620
644
  const finalRows = diversified.slice(0, effectiveLimit);
621
645
  const actualSourceGraphs = [...new Set(finalRows.map((r) => r.graphId || r._use_this_graphId).filter(Boolean))];
646
+ // If the fan-out came back empty ONLY because credit/quota blocked the calls,
647
+ // surface that explicitly — never let it read as a "no coverage" gap.
648
+ if (allRows.length === 0 && creditRejection) {
649
+ const trialResult = await handleTrialCreditExhaustion(creditRejection, apiKey, userId);
650
+ if (trialResult)
651
+ return trialResult;
652
+ return await handleAccessError(creditRejection, 'search_graph');
653
+ }
622
654
  data = { rows: finalRows, dataStatus: allRows.length > 0 ? 'ok' : 'NO_MATCH', _routed_graphs: actualSourceGraphs };
623
655
  }
624
656
  else {
@@ -797,7 +829,7 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
797
829
  catch { /* Broadening failed silently */ }
798
830
  }
799
831
  // ── Low-credit warning for all users — utilizes dynamic Stripe links from API ──
800
- appendUsageWarning(data, isTrial, resolveUserId(userId));
832
+ appendUsageWarning(data, resolveUserId(userId));
801
833
  // ── Supplemental data — macro context for all queries with results ──
802
834
  let supplemental = { google_trends: null, census_retail: null };
803
835
  const resultCount = (data?.rows || []).length;
@@ -872,7 +904,7 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
872
904
  if (totalSize > 30000) {
873
905
  console.error(`[search_graph] Payload too large (${(totalSize / 1024).toFixed(1)}KB) — skipping widget HTML, sending liteData JSON + design brief`);
874
906
  // ── Query-level billing (large payload path) ──
875
- chargeQuery({ queryTypeCode: 'topic_research', apiKey, userId: resolveUserId(userId, uid), query, foddaRequest })
907
+ chargeQuery({ queryTypeCode: 'topic_research', apiKey, userId: resolveUserId(userId, uid), query, foddaRequest, spt: sptCtx?.token })
876
908
  .catch(e => console.error('[search_graph] chargeQuery failed:', e.message));
877
909
  return { content: [
878
910
  { type: 'text', text: jsonPayload },
@@ -892,7 +924,7 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
892
924
  })),
893
925
  ] };
894
926
  // ── Query-level billing (rich widget path) ──
895
- chargeQuery({ queryTypeCode: 'topic_research', apiKey, userId: resolveUserId(userId, uid), query, foddaRequest })
927
+ chargeQuery({ queryTypeCode: 'topic_research', apiKey, userId: resolveUserId(userId, uid), query, foddaRequest, spt: sptCtx?.token })
896
928
  .catch(e => console.error('[search_graph] chargeQuery failed:', e.message));
897
929
  return widgetResponse;
898
930
  }
@@ -924,7 +956,7 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
924
956
  return out;
925
957
  });
926
958
  // ── Query-level billing (fallback path) ──
927
- chargeQuery({ queryTypeCode: 'topic_research', apiKey, userId: resolveUserId(userId, uid), query, foddaRequest })
959
+ chargeQuery({ queryTypeCode: 'topic_research', apiKey, userId: resolveUserId(userId, uid), query, foddaRequest, spt: sptCtx?.token })
928
960
  .catch(e => console.error('[search_graph] chargeQuery failed:', e.message));
929
961
  return { content: [
930
962
  { type: 'text', text: JSON.stringify(fallbackData, null, 2) },
@@ -949,16 +981,17 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
949
981
  ] };
950
982
  }
951
983
  catch (err) {
952
- // Trial-aware credit exhaustion handling
984
+ // Trial-aware credit exhaustion, then structured access/credit handling.
985
+ // (Routes credit errors through handleAccessError so payment details are
986
+ // returned as structured fields, not baked into a raw message string.)
953
987
  const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
954
988
  if (trialResult)
955
989
  return trialResult;
956
- const msg = err.response?.data?.error?.message || err.response?.data?.message || err.message;
957
- return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
990
+ return await handleAccessError(err, 'search_graph');
958
991
  }
959
992
  });
960
993
  // --- get_neighbors ---
961
- server.tool('get_neighbors', 'Traverse graph relationships from a specific trend node to discover connected signals, brands, technologies, and locations. Returns structured relationship data that web search cannot provide — the curated editorial connections between trends. Use after search_graph to map the territory around a specific trend, find which brands are connected, or understand cross-domain links. Requires node_id from a prior search_graph result.', {
994
+ server.tool('get_neighbors', 'Discover what\'s connected to a specific trend — related brands, technologies, locations, and cross-domain links that search alone wouldn\'t surface. Returns curated editorial connections between trends that web search cannot provide. Use after search_graph to map the territory around a trend, find which brands are connected, or understand cross-domain relationships. Requires node_id from a prior search_graph result.', {
962
995
  graphId: z.string().describe(GRAPH_ID_DESC),
963
996
  seed_node_ids: z.array(z.string()).describe('Array of node IDs to start traversal from. MUST be actual node_id values from a prior search_graph result (e.g. ["2507.0"]). Node IDs are NOT sequential integers — do NOT guess or invent IDs like "1", "2", "3". Always call search_graph first to obtain valid IDs.'),
964
997
  userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
@@ -978,7 +1011,7 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
978
1011
  if (direction)
979
1012
  body.direction = direction;
980
1013
  let data = await foddaRequest('POST', `/v1/graphs/${encodeURIComponent(graphId)}/neighbors`, apiKey, resolveUserId(userId, uid), body);
981
- appendUsageWarning(data, isTrial, resolveUserId(userId));
1014
+ appendUsageWarning(data, resolveUserId(userId));
982
1015
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
983
1016
  }
984
1017
  catch (err) {
@@ -990,7 +1023,7 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
990
1023
  }
991
1024
  });
992
1025
  // --- get_evidence ---
993
- server.tool('get_evidence', 'Retrieve curated source articles and structured evidence for a specific trend node — case studies, statistics, expert quotes, and analysis with full source attribution. Returns evidence that has been editorially selected and categorized, not raw web results. Each item includes sourceUrl, place, brandNames, publishedAt, category, and formatted_citation. Use after search_graph when you need the supporting proof behind a specific trend. This is a node lookup — not a text search tool.', {
1026
+ server.tool('get_evidence', 'Get the source articles, case studies, and statistics behind a specific trend — with full citations and publisher attribution. Each item includes source URL, location, brand names, publication date, category, and a formatted citation. Use after search_graph when you need the supporting proof behind a trend. This is a direct lookup by trend ID — not a text search tool.', {
994
1027
  graphId: z.string().describe(GRAPH_ID_DESC),
995
1028
  for_node_id: z.string().describe("The node_id from a prior search_graph result (e.g. '2507.0'). MUST come from the search result's node_id field. Node IDs are NOT sequential integers — do NOT guess or invent IDs like '1', '2', '3'. Do NOT pass the trend name."),
996
1029
  userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
@@ -1005,7 +1038,10 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
1005
1038
  // Enrich evidence with pre-formatted citations
1006
1039
  if (data?.evidence)
1007
1040
  data.evidence = enrichEvidence(data.evidence);
1008
- appendUsageWarning(data, isTrial, resolveUserId(userId));
1041
+ appendUsageWarning(data, resolveUserId(userId));
1042
+ const withheld = await settleOrWithhold({ queryTypeCode: 'standalone_evidence', apiKey, userId: resolveUserId(userId, uid), query: for_node_id }, 'get_evidence');
1043
+ if (withheld)
1044
+ return withheld;
1009
1045
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1010
1046
  }
1011
1047
  catch (err) {
@@ -1017,7 +1053,7 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
1017
1053
  }
1018
1054
  });
1019
1055
  // --- get_node ---
1020
- server.tool('get_node', 'Retrieve complete metadata for a specific trend node — full description, signal score, lifecycle, geographic scope, adjacent possibilities, and all properties. Use when you need the full detail on a single trend after search_graph returned a summary. Requires node_id from a prior search_graph result.', {
1056
+ server.tool('get_node', 'Get the full profile of a specific trend — detailed description, lifecycle stage (emerging/building/mature), signal strength, geographic scope, and all properties. Use when you need deeper detail on a single trend after search_graph returned a summary. Requires node_id from a prior search_graph result.', {
1021
1057
  graphId: z.string().describe(GRAPH_ID_DESC),
1022
1058
  nodeId: z.string().describe("The node_id from a prior search_graph result (e.g. '2507.0'). MUST come from the search result's node_id field. Node IDs are NOT sequential integers — do NOT guess or invent IDs like '1', '2', '3'. Do NOT pass the trend name."),
1023
1059
  userId: z.string().optional().describe('Optional user identifier for trial usage tracking.')
@@ -1030,7 +1066,7 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
1030
1066
  if (data && typeof data === 'object') {
1031
1067
  data.theme = getFoddaTheme(graphId);
1032
1068
  }
1033
- appendUsageWarning(data, isTrial, resolveUserId(userId));
1069
+ appendUsageWarning(data, resolveUserId(userId));
1034
1070
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1035
1071
  }
1036
1072
  catch (err) {
@@ -1042,7 +1078,7 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
1042
1078
  }
1043
1079
  });
1044
1080
  // --- get_label_values ---
1045
- server.tool('get_label_values', 'List all values for a structured category within a graph — Brand names, Locations, Technologies, Audiences, RetailerTypes, or Trend names. Use when you need to enumerate what entities exist in a graph before filtering, or when the user asks "what brands are in the retail graph?" or "what locations does the fashion graph cover?". To enumerate every trend in a graph (especially industry-report graphs, where semantic search/search_insights may return only the top match or nothing if evidence is unlinked), call with label="Trend" — this returns the complete, deterministic list of trend names.', {
1081
+ server.tool('get_label_values', 'List all brands, locations, technologies, audiences, or trends within a specific knowledge graph. Use to explore what a graph contains — e.g., "what brands are in the retail graph?" or "what locations does the fashion graph cover?". To get a complete list of every trend in a graph, call with label="Trend" — this returns the full deterministic list, useful for industry-report graphs where search may return partial results.', {
1046
1082
  graphId: z.string().describe(GRAPH_ID_DESC),
1047
1083
  label: z.string().describe("The label to fetch values for (e.g., 'Brand', 'Location', 'Technology', 'Audience', 'RetailerType', 'Trend')"),
1048
1084
  userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
@@ -1064,7 +1100,7 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
1064
1100
  }
1065
1101
  });
1066
1102
  // --- discover_adjacent_trends ---
1067
- server.tool('discover_adjacent_trends', 'Find trends semantically similar to a given trend using pre-computed embeddings — surfaces connections that keyword search would miss. Returns scored similarity matches and optionally editorial links across graphs. Use to expand research briefs, discover unexpected cross-domain connections, or map the territory around a strong signal. Web search cannot replicate this — it uses Fodda\'s internal embedding space.', {
1103
+ server.tool('discover_adjacent_trends', 'Find trends similar to one you\'ve already found — surfaces unexpected cross-domain connections that keyword search would miss. Returns scored similarity matches and optionally editorial links across graphs. Use to expand research briefs, discover cross-industry parallels, or map the territory around a strong signal. This leverages Fodda\'s proprietary similarity index across all knowledge graphs.', {
1068
1104
  graphId: z.string().describe(GRAPH_ID_DESC),
1069
1105
  trend_id: z.string().describe("The node_id from a prior search_graph result (e.g. '2507.0'). MUST come from the search result's node_id field. Node IDs are NOT sequential integers — do NOT guess or invent IDs like '1', '2', '3'. Do NOT pass the trend name."),
1070
1106
  userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
@@ -1075,6 +1111,8 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
1075
1111
  try {
1076
1112
  if (graphId === 'psfk')
1077
1113
  graphId = 'retail';
1114
+ // Log query to Questions table (fire-and-forget, before cache)
1115
+ logUserQuery(trend_id, 'discover_adjacent_trends', graphId);
1078
1116
  const params = new URLSearchParams({ node_id: trend_id });
1079
1117
  if (min_score !== undefined)
1080
1118
  params.set('min_score', String(min_score));
@@ -1082,7 +1120,10 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
1082
1120
  if (include_editorial !== undefined)
1083
1121
  params.set('include_editorial', String(include_editorial));
1084
1122
  let data = await foddaRequest('GET', `/v1/graphs/${encodeURIComponent(graphId)}/adjacent?${params.toString()}`, apiKey, resolveUserId(userId, uid));
1085
- appendUsageWarning(data, isTrial, resolveUserId(userId));
1123
+ appendUsageWarning(data, resolveUserId(userId));
1124
+ const adjacentWithheld = await settleOrWithhold({ queryTypeCode: 'adjacent_trends', apiKey, userId: resolveUserId(userId, uid), query: trend_id }, 'discover_adjacent_trends');
1125
+ if (adjacentWithheld)
1126
+ return adjacentWithheld;
1086
1127
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1087
1128
  }
1088
1129
  catch (err) {
@@ -1100,8 +1141,13 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
1100
1141
  const maxEv = Math.min(max_evidence || 10, 25);
1101
1142
  const brandLower = brandName.toLowerCase();
1102
1143
  // ── Fire static/brand-only supplemental queries in parallel ──
1103
- const amazonPromise = foddaRequest('GET', `/v1/supplemental/amazon?query=${encodeURIComponent(brandName)}&limit=8`, apiKey, resolveUserId(userId, uid));
1104
- const censusPromise = foddaRequest('GET', `/v1/supplemental/census/retail-snapshot`, apiKey, resolveUserId(userId, uid));
1144
+ // These are created early but only awaited (via Promise.allSettled) ~500 lines later.
1145
+ // A .catch() at creation is MANDATORY: if one rejects (e.g. a transient 503) during the
1146
+ // intervening awaits, an unhandled rejection would crash the whole MCP process. Degrade to null.
1147
+ const amazonPromise = foddaRequest('GET', `/v1/supplemental/amazon?query=${encodeURIComponent(brandName)}&limit=8`, apiKey, resolveUserId(userId, uid))
1148
+ .catch((e) => { console.warn('[brand_tracker] amazon supplemental failed:', e?.message); return null; });
1149
+ const censusPromise = foddaRequest('GET', `/v1/supplemental/census/retail-snapshot`, apiKey, resolveUserId(userId, uid))
1150
+ .catch((e) => { console.warn('[brand_tracker] census supplemental failed:', e?.message); return null; });
1105
1151
  // Build graph lookup map
1106
1152
  const graphLookup = new Map();
1107
1153
  for (const g of getGraphs())
@@ -1620,10 +1666,15 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
1620
1666
  try {
1621
1667
  // Log query to Questions table (fire-and-forget, before cache)
1622
1668
  logUserQuery(brand_name, 'brand_tracker');
1669
+ // ── SPT pre-run coverage: refuse before spending compute if the token can't cover the task ──
1670
+ const guard = sptGuard('brand_intelligence');
1671
+ if (guard)
1672
+ return guard;
1623
1673
  const { widget, EDITORIAL_INSTRUCTION } = await executeBrandTracker(brand_name, uid, graph_ids, include_evidence, max_evidence);
1624
- // ── Query-level billing ──
1625
- chargeQuery({ queryTypeCode: 'brand_intelligence', apiKey, userId: resolveUserId(userId, uid), query: brand_name, foddaRequest })
1626
- .catch(e => console.error('[brand_tracker] chargeQuery failed:', e.message));
1674
+ // ── Query-level billing (settlement gates delivery for SPT) ──
1675
+ const withheld = await settleOrWithhold({ queryTypeCode: 'brand_intelligence', apiKey, userId: resolveUserId(userId, uid), query: brand_name }, 'brand_tracker');
1676
+ if (withheld)
1677
+ return withheld;
1627
1678
  return { content: [
1628
1679
  { type: 'text', text: '── WIDGET HTML ──\nIf your client supports HTML visualization (show_widget, visualize:show_widget, or artifacts), pass this HTML verbatim. Do not rewrite or restyle.\n\n' + widget.widget_html },
1629
1680
  { type: 'text', text: EDITORIAL_INSTRUCTION },
@@ -1641,7 +1692,7 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
1641
1692
  // Replaces 21 individual supplemental tools with a single call.
1642
1693
  // The API routes to 6-10 relevant sources based on query + domain,
1643
1694
  // queries them in parallel, and returns a consolidated response.
1644
- server.tool('get_supplemental_context', 'Get institutional market data from up to 10 sources in a single call. The server automatically selects the most relevant sources based on your query and domain — economic indicators, trade data, product landscape, food economics, agricultural production, nutritional composition, commodity pricing, research signals, demographic surveys, and more. Use this AFTER graph searches to add macro context, or standalone for quantitative market intelligence. Returns categorized data blocks (demand_signals, economic_context, market_data, research_signals, demographic_context) with source attribution for citations. 5 API calls per standalone use.', {
1695
+ server.tool('get_supplemental_context', 'Targeted pull for macro/institutional data — research workflows include this automatically; call directly only for standalone economic context. Gets real-time market data from 80+ authoritative sources in a single call — economic indicators, trade statistics, consumer demand signals, research trends, demographics, and more. The server automatically selects the most relevant sources for your query. Returns categorized data blocks (demand_signals, economic_context, market_data, research_signals, demographic_context) with source attribution for citations. Uses 5 tokens ($2.50 via SPT) per standalone use.', {
1645
1696
  query: z.string().describe("The topic or query to get supplemental data for (e.g., 'sustainable packaging', 'tequila spirits market', 'Gen Z beauty')"),
1646
1697
  domain: z.string().optional().describe("Domain hint to improve source routing: 'retail', 'beauty', 'fashion', 'sports', 'food', 'technology', 'culture', 'travel', 'design'. If omitted, inferred from query."),
1647
1698
  brands: z.array(z.string()).optional().describe("Brand names to include in demand/product lookups (e.g., ['Nike', 'Adidas']). Triggers Google Trends comparison and Amazon product search."),
@@ -1649,6 +1700,8 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
1649
1700
  userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
1650
1701
  }, { title: 'Get Market Context Data', readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ query, domain, brands, graph_ids, userId: uid }) => {
1651
1702
  try {
1703
+ // Log query to Questions table (fire-and-forget, before cache)
1704
+ logUserQuery(query, 'supplemental_context');
1652
1705
  const body = { query };
1653
1706
  if (domain)
1654
1707
  body.domain = domain;
@@ -1663,7 +1716,7 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
1663
1716
  try {
1664
1717
  const data = await foddaRequest('POST', '/v1/supplemental/context', apiKey, resolveUserId(userId, uid), body);
1665
1718
  // ── Query-level billing ──
1666
- chargeQuery({ queryTypeCode: 'standalone_supplemental', apiKey, userId: resolveUserId(userId, uid), query, foddaRequest })
1719
+ chargeQuery({ queryTypeCode: 'standalone_supplemental', apiKey, userId: resolveUserId(userId, uid), query, foddaRequest, spt: sptCtx?.token })
1667
1720
  .catch(e => console.error('[supplemental] chargeQuery failed:', e.message));
1668
1721
  activeSupplementalJobs.set(jobId, { status: 'COMPLETE', result: JSON.stringify(data, null, 2) });
1669
1722
  }
@@ -1687,7 +1740,7 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
1687
1740
  }
1688
1741
  });
1689
1742
  // --- check_supplemental_status ---
1690
- server.tool('check_supplemental_status', 'Check the status of a long-running supplemental data gathering job. If complete, this tool returns the full JSON data payload. You MUST poll this periodically until the status is COMPLETE or FAILED.', {
1743
+ server.tool('check_supplemental_status', 'Check if market data gathering is complete and retrieve the results. Call this after get_supplemental_context — poll every 5-10 seconds until status is COMPLETE or FAILED.', {
1691
1744
  job_id: z.string().describe('The Job ID returned by get_supplemental_context'),
1692
1745
  }, { title: 'Check Supplemental Status', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ job_id }) => {
1693
1746
  const job = activeSupplementalJobs.get(job_id);
@@ -1727,7 +1780,12 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
1727
1780
  body.max_evidence_per_trend = max_evidence_per_trend;
1728
1781
  if (min_score !== undefined)
1729
1782
  body.min_score = min_score;
1783
+ // Log query to Questions table (fire-and-forget, before cache)
1784
+ logUserQuery(query, 'domain_intelligence');
1730
1785
  const data = await foddaRequest('POST', '/v1/search/domain', apiKey, resolveUserId(userId, uid), body);
1786
+ const domainWithheld = await settleOrWithhold({ queryTypeCode: 'domain_intelligence', apiKey, userId: resolveUserId(userId, uid), query }, 'get_domain_intelligence');
1787
+ if (domainWithheld)
1788
+ return domainWithheld;
1731
1789
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1732
1790
  }
1733
1791
  catch (err) {
@@ -1757,7 +1815,12 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
1757
1815
  body.max_evidence_per_trend = max_evidence_per_trend;
1758
1816
  if (min_score !== undefined)
1759
1817
  body.min_score = min_score;
1818
+ // Log query to Questions table (fire-and-forget, before cache)
1819
+ logUserQuery(query, 'expert_intelligence');
1760
1820
  const data = await foddaRequest('POST', '/v1/search/expert', apiKey, resolveUserId(userId, uid), body);
1821
+ const expertWithheld = await settleOrWithhold({ queryTypeCode: 'expert_intelligence', apiKey, userId: resolveUserId(userId, uid), query }, 'get_expert_intelligence');
1822
+ if (expertWithheld)
1823
+ return expertWithheld;
1761
1824
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1762
1825
  }
1763
1826
  catch (err) {
@@ -1787,7 +1850,12 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
1787
1850
  body.max_evidence_per_trend = max_evidence_per_trend;
1788
1851
  if (min_score !== undefined)
1789
1852
  body.min_score = min_score;
1853
+ // Log query to Questions table (fire-and-forget, before cache)
1854
+ logUserQuery(query, 'report_intelligence');
1790
1855
  const data = await foddaRequest('POST', '/v1/search/report', apiKey, resolveUserId(userId, uid), body);
1856
+ const reportWithheld = await settleOrWithhold({ queryTypeCode: 'report_intelligence', apiKey, userId: resolveUserId(userId, uid), query }, 'get_report_intelligence');
1857
+ if (reportWithheld)
1858
+ return reportWithheld;
1791
1859
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1792
1860
  }
1793
1861
  catch (err) {
@@ -1798,7 +1866,7 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
1798
1866
  }
1799
1867
  });
1800
1868
  // --- search_statistics ---
1801
- server.tool('search_statistics', "Search for specific quantitative data points — market sizes, growth rates, expert quotes, and brand case studies — directly in Fodda's knowledge graphs. Each result includes its parent trend context, enabling reverse lookup: data point → expert trend. Use this BEFORE supplemental tools when a query asks for specific numbers or statistics that Fodda's experts may have covered. Works on ALL graphs — PSFK curated graphs AND expert graphs. Try multiple graphs for coverage.", {
1869
+ server.tool('search_statistics', "Find specific numbers, market sizes, growth rates, and quantitative data points across Fodda's knowledge graphs. Each result links back to the expert trend it supports. Use when a question asks for specific statistics — try this BEFORE supplemental data tools, as Fodda's experts may have already curated the answer. Works on ALL graphs — domain, expert, and report graphs. Search multiple graphs for best coverage.", {
1802
1870
  graph_id: z.string().describe("Graph ID to search. Works on ALL graphs — PSFK curated ('retail', 'fashion', 'beauty', 'sports', 'sic', 'ce-design', 'pew') AND expert graphs. Search across multiple graphs for best coverage."),
1803
1871
  query: z.string().describe("What data to search for (e.g., 'luxury resale market size', 'secondhand clothing sales volume', 'Gen Z spending behavior')"),
1804
1872
  limit: z.number().optional().describe('Max results to return (default: 10, max: 50)'),
@@ -1807,6 +1875,8 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
1807
1875
  userId: z.string().optional().describe('Optional user identifier for trial usage tracking.')
1808
1876
  }, { title: 'Search Statistics & Data Points', readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ graph_id, query, limit, min_score, include_signals, userId: uid }) => {
1809
1877
  try {
1878
+ // Log query to Questions table (fire-and-forget, before cache)
1879
+ logUserQuery(query, 'search_statistics', graph_id);
1810
1880
  const params = new URLSearchParams();
1811
1881
  params.set('query', query);
1812
1882
  if (limit !== undefined)
@@ -1821,6 +1891,9 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
1821
1891
  if (data && typeof data === 'object') {
1822
1892
  data.theme = getFoddaTheme(graph_id);
1823
1893
  }
1894
+ const statsWithheld = await settleOrWithhold({ queryTypeCode: 'standalone_statistics', apiKey, userId: resolveUserId(userId, uid), query }, 'search_statistics');
1895
+ if (statsWithheld)
1896
+ return statsWithheld;
1824
1897
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1825
1898
  }
1826
1899
  catch (err) {
@@ -1831,7 +1904,7 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
1831
1904
  }
1832
1905
  });
1833
1906
  // --- search_insights ---
1834
- server.tool('search_insights', 'Search for expert quotes, editorial interpretations, and qualitative evidence across knowledge graphs. Returns categorized evidence (metric, quote, interpretation, signal) with source attribution and parent trend context. Works on ALL graphs. Use when you need named-expert voices, strategic framing, or analytical perspectives on a topic — the kind of curated qualitative intelligence that web search cannot surface because it lives inside structured knowledge graphs, not on public web pages.', {
1907
+ server.tool('search_insights', 'Find expert quotes, editorial analysis, and strategic perspectives on a topic — sourced from named strategists and industry leaders. Returns categorized evidence (metrics, quotes, interpretations, signals) with source attribution and parent trend context. Works on ALL graphs. Use when you need authoritative voices, strategic framing, or analytical depth that web search cannot provide.', {
1835
1908
  graph_id: z.string().describe("Graph ID to search. Works on ALL graphs — PSFK curated ('retail', 'sic', 'beauty', 'sports', 'fashion', 'ce-design', 'pew') AND expert graphs. Search across multiple graphs for best coverage."),
1836
1909
  query: z.string().describe("Natural language search query. E.g. 'expert views on Gen Z luxury' or 'resale market statistics'"),
1837
1910
  types: z.string().optional().describe("Comma-separated evidence types to search: metric, quote, interpretation, signal, or 'all' (default: 'metric,quote,interpretation')"),
@@ -1840,6 +1913,8 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
1840
1913
  userId: z.string().optional().describe('Optional user identifier for trial usage tracking.')
1841
1914
  }, { title: 'Search Expert Insights', readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ graph_id, query, types, limit, min_score, userId: uid }) => {
1842
1915
  try {
1916
+ // Log query to Questions table (fire-and-forget, before cache)
1917
+ logUserQuery(query, 'search_insights', graph_id);
1843
1918
  const searchTypes = types || 'metric,quote,interpretation';
1844
1919
  const params = new URLSearchParams();
1845
1920
  params.set('query', query);
@@ -1850,6 +1925,9 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
1850
1925
  params.set('min_score', String(min_score));
1851
1926
  const path = `/v1/graphs/${graph_id}/statistics?${params.toString()}`;
1852
1927
  const data = await foddaRequest('GET', path, apiKey, resolveUserId(userId, uid));
1928
+ const insightsWithheld = await settleOrWithhold({ queryTypeCode: 'standalone_insights', apiKey, userId: resolveUserId(userId, uid), query }, 'search_insights');
1929
+ if (insightsWithheld)
1930
+ return insightsWithheld;
1853
1931
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1854
1932
  }
1855
1933
  catch (err) {
@@ -1863,7 +1941,7 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
1863
1941
  // Cross-company and industry-level earnings call intelligence.
1864
1942
  // For single-brand earnings, brand_tracker already includes earningsIntelligence.
1865
1943
  // This tool is for: multi-company comparisons, industry/sector filters, and explicit earnings queries.
1866
- server.tool('get_earnings_intelligence', 'Query earnings call intelligence across companies, industries, or sectors. Returns structured evidence from public company earnings calls — management commentary, guidance, key topics, and analyst Q&A. Use for cross-company comparisons ("what are hotel companies saying about labor costs?"), industry-level queries ("earnings intelligence for consumer electronics"), or explicit earnings requests. For single-brand earnings, use brand_tracker instead — it includes earningsIntelligence automatically. Results include a source field: "knowledge_graph" (high confidence, structured Neo4j data) or "web_supplemental" (backfilled via web search). 5 API calls per use.', {
1944
+ server.tool('get_earnings_intelligence', 'Targeted pull: use when you want ONLY earnings data for a known ticker/sector — not full research. For questions that mix earnings with trends, use deep_research_topic, which includes earnings automatically. Returns structured evidence from public company earnings calls — management commentary, guidance, key topics, and analyst Q&A. Use for cross-company comparisons ("what are hotel companies saying about labor costs?"), industry-level queries ("earnings intelligence for consumer electronics"), or explicit earnings requests. For single-brand earnings, use brand_tracker instead — it includes earningsIntelligence automatically. Results include a source field: "knowledge_graph" (high confidence, structured Neo4j data) or "web_supplemental" (backfilled via web search). Uses 5 tokens ($2.50 via SPT). For cross-company thematic comparisons. For per-ticker data (SWOT, sentiment, guidance), use get_company_earnings instead.', {
1867
1945
  ticker: z.string().optional().describe("Company stock ticker (e.g., 'NKE', 'LVMUY', 'HLT'). At least one filter required."),
1868
1946
  brand: z.string().optional().describe("Brand name for fuzzy matching (e.g., 'Nike', 'Marriott')"),
1869
1947
  industry: z.string().optional().describe("Industry filter (e.g., 'hotels', 'sportswear', 'consumer electronics')"),
@@ -1875,6 +1953,8 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
1875
1953
  userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
1876
1954
  }, { title: 'Query Earnings Call Intelligence', readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ ticker, brand, industry, sector, search, dateFrom, dateTo, limit, userId: uid }) => {
1877
1955
  try {
1956
+ // Log query to Questions table (fire-and-forget, before cache)
1957
+ logUserQuery(search || brand || ticker || industry || sector || 'earnings snapshot', 'earnings_intelligence');
1878
1958
  const params = new URLSearchParams();
1879
1959
  if (ticker)
1880
1960
  params.set('ticker', ticker);
@@ -1893,10 +1973,14 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
1893
1973
  if (limit !== undefined)
1894
1974
  params.set('limit', String(Math.min(limit, 50)));
1895
1975
  const qs = params.toString();
1976
+ const earningsGuard = sptGuard('earnings_intelligence');
1977
+ if (earningsGuard)
1978
+ return earningsGuard;
1896
1979
  const data = await foddaRequest('GET', `/v1/supplemental/earnings/snapshot${qs ? '?' + qs : ''}`, apiKey, resolveUserId(userId, uid));
1897
- // ── Query-level billing ──
1898
- chargeQuery({ queryTypeCode: 'earnings_intelligence', apiKey, userId: resolveUserId(userId, uid), query: search || brand || ticker || sector || '', foddaRequest })
1899
- .catch(e => console.error('[get_earnings_intelligence] chargeQuery failed:', e.message));
1980
+ // ── Query-level billing (settlement gates delivery for SPT) ──
1981
+ const earningsWithheld = await settleOrWithhold({ queryTypeCode: 'earnings_intelligence', apiKey, userId: resolveUserId(userId, uid), query: search || brand || ticker || sector || '' }, 'get_earnings_intelligence');
1982
+ if (earningsWithheld)
1983
+ return earningsWithheld;
1900
1984
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1901
1985
  }
1902
1986
  catch (err) {
@@ -1909,7 +1993,7 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
1909
1993
  // --- get_earnings_divergence ---
1910
1994
  // Gaps between what analysts are concerned about and how management responds.
1911
1995
  // This is premium intelligence — surfaces deflection and narrative mismatches.
1912
- server.tool('get_earnings_divergence', 'Detect divergence between analyst concerns and management responses in earnings calls. Surfaces where executives are deflecting, reframing, or avoiding specific topics. Premium intelligence — shows the gap between what Wall Street is worried about and what companies are saying. Results include deflected topics, concern-vs-response framing, and connections to Fodda trends via :VALIDATES edges. Use for "where are executives deflecting?" or "divergence in [sector] earnings." 5 API calls per use.', {
1996
+ server.tool('get_earnings_divergence', 'Targeted pull: use when you want ONLY earnings data for a known ticker/sector — not full research. For questions that mix earnings with trends, use deep_research_topic, which includes earnings automatically. Detects divergence between analyst concerns and management responses in earnings calls — surfaces where executives are deflecting, reframing, or avoiding specific topics. Premium intelligence — shows the gap between what Wall Street is worried about and what companies are saying. Results include deflected topics, concern-vs-response framing, and connections to Fodda trends via :VALIDATES edges. Use for "where are executives deflecting?" or "divergence in [sector] earnings." Uses 5 tokens ($2.50 via SPT). Detects management deflection of analyst concerns.', {
1913
1997
  sector: z.string().optional().describe("Sector filter (e.g., 'retail', 'technology', 'travel')"),
1914
1998
  industry: z.string().optional().describe("Industry filter (e.g., 'hotels', 'sportswear', 'luxury')"),
1915
1999
  search: z.string().optional().describe("Free text search (e.g., 'tariffs', 'AI capex', 'margin erosion')"),
@@ -1919,6 +2003,8 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
1919
2003
  userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
1920
2004
  }, { title: 'Detect Earnings Call Divergence', readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ sector, industry, search, dateFrom, dateTo, limit, userId: uid }) => {
1921
2005
  try {
2006
+ // Log query to Questions table (fire-and-forget, before cache)
2007
+ logUserQuery(search || sector || industry || 'earnings divergence', 'earnings_divergence');
1922
2008
  const params = new URLSearchParams();
1923
2009
  if (sector)
1924
2010
  params.set('sector', sector);
@@ -1933,10 +2019,116 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
1933
2019
  if (limit !== undefined)
1934
2020
  params.set('limit', String(Math.min(limit, 25)));
1935
2021
  const qs = params.toString();
2022
+ const divergenceGuard = sptGuard('earnings_intelligence');
2023
+ if (divergenceGuard)
2024
+ return divergenceGuard;
1936
2025
  const data = await foddaRequest('GET', `/v1/supplemental/earnings/divergence${qs ? '?' + qs : ''}`, apiKey, resolveUserId(userId, uid));
1937
- // ── Query-level billing ──
1938
- chargeQuery({ queryTypeCode: 'earnings_intelligence', apiKey, userId: resolveUserId(userId, uid), query: search || sector || industry || 'divergence', foddaRequest })
1939
- .catch(e => console.error('[get_earnings_divergence] chargeQuery failed:', e.message));
2026
+ // ── Query-level billing (settlement gates delivery for SPT) ──
2027
+ const divergenceWithheld = await settleOrWithhold({ queryTypeCode: 'earnings_intelligence', apiKey, userId: resolveUserId(userId, uid), query: search || sector || industry || 'divergence' }, 'get_earnings_divergence');
2028
+ if (divergenceWithheld)
2029
+ return divergenceWithheld;
2030
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
2031
+ }
2032
+ catch (err) {
2033
+ const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
2034
+ if (trialResult)
2035
+ return trialResult;
2036
+ return await handleAccessError(err, 'supplemental');
2037
+ }
2038
+ });
2039
+ // --- get_company_earnings ---
2040
+ // Per-ticker earnings intelligence: SWOT scores, sentiment, guidance, Q&A, competitive analysis.
2041
+ // One tool with a `view` parameter — not 5 separate tools (context budget).
2042
+ server.tool('get_company_earnings', 'Get per-ticker earnings intelligence for consumer-sector public companies. Returns SWOT scores, CEO/analyst sentiment, guidance status, competitive analysis, and validated consumer trends from Fodda\'s quarterly analysis pipeline. Uses 0–15 tokens depending on view (coverage is free, snapshot/history/guidance = 10, qa = 5, compare = 15). Use this for company-specific data. Use get_earnings_intelligence for cross-company thematic comparisons.', {
2043
+ view: z.enum(['snapshot', 'history', 'qa', 'compare', 'guidance', 'coverage']).default('snapshot').describe('snapshot: full quarterly record with SWOT, sentiment, guidance, validated trends (10 tokens). history: SWOT/sentiment scores across quarters for trend analysis (10 tokens). qa: per-analyst Q&A entries with thematic tagging (5 tokens). compare: side-by-side comparison of 2-5 tickers (15 tokens). guidance: cross-coverage guidance changes — raised/cut/withdrawn (10 tokens). coverage: list all covered tickers (free).'),
2044
+ ticker: z.string().optional().describe("Company ticker symbol (e.g. NKE, LULU, ONON). Required for snapshot, history, and qa views."),
2045
+ tickers: z.string().optional().describe("Comma-separated ticker symbols for compare view (2-5 tickers). Only used when view=compare."),
2046
+ period: z.string().optional().describe("Quarter filter (e.g. Q1-2026). Defaults to latest quarter."),
2047
+ metrics: z.string().optional().describe("Comma-separated metric names for history view (e.g. swot_total,ceo_sentiment). Defaults to all."),
2048
+ analyst: z.string().optional().describe("Analyst name filter for qa view."),
2049
+ sector: z.string().optional().describe("Sector filter for guidance view."),
2050
+ userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
2051
+ }, { title: 'Get Company Earnings', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ view, ticker, tickers, period, metrics, analyst, sector, userId: uid }) => {
2052
+ try {
2053
+ logUserQuery(ticker || tickers || sector || view || 'company earnings', 'earnings_company');
2054
+ // ── Validation: require params per view ──
2055
+ if ((view === 'snapshot' || view === 'history' || view === 'qa') && !ticker) {
2056
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'MISSING_PARAM', message: `The "${view}" view requires a ticker parameter (e.g. ticker: "NKE").` }) }] };
2057
+ }
2058
+ if (view === 'compare' && !tickers) {
2059
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'MISSING_PARAM', message: 'The "compare" view requires a tickers parameter with 2-5 comma-separated ticker symbols (e.g. tickers: "NKE,LULU,ONON").' }) }] };
2060
+ }
2061
+ // ── Route to API endpoint per view ──
2062
+ let path;
2063
+ let queryTypeCode;
2064
+ switch (view) {
2065
+ case 'snapshot': {
2066
+ const params = new URLSearchParams();
2067
+ if (period)
2068
+ params.set('period', period);
2069
+ const qs = params.toString();
2070
+ path = `/v1/earnings/company/${encodeURIComponent(ticker)}${qs ? '?' + qs : ''}`;
2071
+ queryTypeCode = 'earnings_company';
2072
+ break;
2073
+ }
2074
+ case 'history': {
2075
+ const params = new URLSearchParams();
2076
+ if (metrics)
2077
+ params.set('metrics', metrics);
2078
+ const qs = params.toString();
2079
+ path = `/v1/earnings/company/${encodeURIComponent(ticker)}/history${qs ? '?' + qs : ''}`;
2080
+ queryTypeCode = 'earnings_history';
2081
+ break;
2082
+ }
2083
+ case 'qa': {
2084
+ const params = new URLSearchParams();
2085
+ if (period)
2086
+ params.set('period', period);
2087
+ if (analyst)
2088
+ params.set('analyst', analyst);
2089
+ const qs = params.toString();
2090
+ path = `/v1/earnings/company/${encodeURIComponent(ticker)}/qa${qs ? '?' + qs : ''}`;
2091
+ queryTypeCode = 'earnings_qa';
2092
+ break;
2093
+ }
2094
+ case 'compare': {
2095
+ const params = new URLSearchParams();
2096
+ params.set('tickers', tickers);
2097
+ if (period)
2098
+ params.set('period', period);
2099
+ path = `/v1/earnings/compare?${params.toString()}`;
2100
+ queryTypeCode = 'earnings_compare';
2101
+ break;
2102
+ }
2103
+ case 'guidance': {
2104
+ const params = new URLSearchParams();
2105
+ if (sector)
2106
+ params.set('sector', sector);
2107
+ if (period)
2108
+ params.set('period', period);
2109
+ const qs = params.toString();
2110
+ path = `/v1/earnings/guidance-changes${qs ? '?' + qs : ''}`;
2111
+ queryTypeCode = 'earnings_guidance';
2112
+ break;
2113
+ }
2114
+ case 'coverage': {
2115
+ // Free endpoint — no billing
2116
+ const data = await foddaRequest('GET', '/v1/earnings/coverage', apiKey, resolveUserId(userId, uid));
2117
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
2118
+ }
2119
+ default:
2120
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'INVALID_VIEW', message: `Unknown view "${view}". Valid views: snapshot, history, qa, compare, guidance, coverage.` }) }] };
2121
+ }
2122
+ // ── SPT pre-check ──
2123
+ const guard = sptGuard(queryTypeCode);
2124
+ if (guard)
2125
+ return guard;
2126
+ // ── API call ──
2127
+ const data = await foddaRequest('GET', path, apiKey, resolveUserId(userId, uid));
2128
+ // ── Settlement ──
2129
+ const withheld = await settleOrWithhold({ queryTypeCode, apiKey, userId: resolveUserId(userId, uid), query: ticker || tickers || sector || view }, 'get_company_earnings');
2130
+ if (withheld)
2131
+ return withheld;
1940
2132
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1941
2133
  }
1942
2134
  catch (err) {
@@ -1950,7 +2142,7 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
1950
2142
  server.tool('update_user_profile', 'Save the user\'s research profile to improve the relevance of future responses. Call this after you understand the user\'s role, industry, and research needs. The profile persists across sessions — you only need to set it once, then update if their focus changes. Write BEHAVIORAL INSTRUCTIONS, not a bio. Format: one sentence of identity (who they are and how they use Fodda), then numbered directives that change how you synthesize and frame responses. Include: what evidence to prioritize, how to frame conclusions, geographic needs, and output structure preferences. Max 2000 chars per field.', {
1951
2143
  userContext: z.string().describe('Behavioral framing instructions for this person. Format: one sentence of identity, then numbered FRAMING INSTRUCTIONS. Example: "Agency strategist doing time-pressured pitches. (1) Lead with landscape orientation — top 3-5 macro forces. (2) Prioritize commercially validated signals over design concepts. (3) ALWAYS differentiate by geography. (4) Executive-ready framing — concise, pitch-deck-ready. (5) Strongest findings first, not exhaustive lists." Max 2000 chars.'),
1952
2144
  accountContext: z.string().optional().describe('Description of their company: industry, size, key markets, competitive position, mission. Shared across all users on this account. Max 2000 chars.'),
1953
- }, { title: 'Update Research Profile', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ userContext, accountContext }) => {
2145
+ }, { title: 'Update Research Profile', readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, async ({ userContext, accountContext }) => {
1954
2146
  try {
1955
2147
  const body = {};
1956
2148
  if (userContext)
@@ -1973,17 +2165,6 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
1973
2165
  const msg = err.response?.data?.error?.message || err.response?.data?.message || err.message;
1974
2166
  console.error(`[update_user_profile] Error: ${msg}`);
1975
2167
  // Fail gracefully — don't break the conversation
1976
- if (isTrial) {
1977
- return {
1978
- content: [{
1979
- type: 'text',
1980
- text: JSON.stringify({
1981
- status: 'SKIPPED',
1982
- message: 'Profile saving is available for Base accounts and above. I\'ll use this context for the current session. Sign up at app.fodda.ai to persist your preferences.',
1983
- }, null, 2)
1984
- }]
1985
- };
1986
- }
1987
2168
  return {
1988
2169
  content: [{
1989
2170
  type: 'text',
@@ -2001,7 +2182,7 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
2001
2182
  target_id: z.string().describe('The ID of the graph, skill, or data source to toggle (e.g., "paralogy", "igloo", "retail", "get_bea_spending_snapshot"). Use the exact ID from list_graphs.'),
2002
2183
  enabled: z.boolean().describe('true to enable (turn on), false to disable (turn off).'),
2003
2184
  user_email: z.string().optional().describe('Optional. Use ONLY when operating as an Admin on behalf of another user to specify their email.')
2004
- }, { title: 'Toggle Graph or Skill', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ target_id, enabled, user_email }) => {
2185
+ }, { title: 'Toggle Graph or Skill', readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, async ({ target_id, enabled, user_email }) => {
2005
2186
  try {
2006
2187
  const body = { target_id, enabled };
2007
2188
  if (user_email)
@@ -2109,7 +2290,7 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
2109
2290
  });
2110
2291
  // --- sign_up_free_account ---
2111
2292
  const APP_BASE_URL = process.env.FODDA_APP_URL || 'https://app.fodda.ai';
2112
- server.tool('sign_up_free_account', 'Create a Fodda Base account for a trial user who has run out of credits. Call this when a trial user provides their email after being prompted. Creates an account with 100 API calls/month across ALL knowledge graphs and sends a confirmation email. Can also be called with additional profile fields (name, job_title, company).', {
2293
+ server.tool('sign_up_free_account', 'Create a free Fodda Base account (100 API calls/month across ALL knowledge graphs) and send a confirmation email. GUARDRAIL: only call this AFTER the user has explicitly provided their email and asked to create an account — never sign someone up proactively or with an email inferred from earlier context. Can also pass profile fields (name, job_title, company).', {
2113
2294
  email: z.string().describe('User\'s email address (required)'),
2114
2295
  name: z.string().optional().describe('User\'s full name (optional — collect conversationally after signup)'),
2115
2296
  job_title: z.string().optional().describe('User\'s job title (optional)'),
@@ -2186,6 +2367,8 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
2186
2367
  userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
2187
2368
  }, { title: 'Brainstorm & Explore Topic', readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ query, depth, userId: uid }) => {
2188
2369
  try {
2370
+ // Log query to Questions table (fire-and-forget, before cache)
2371
+ logUserQuery(query, 'brainstorm_topic');
2189
2372
  const resolvedUserId = resolveUserId(userId, uid);
2190
2373
  const traversalDepth = Math.min(depth || 2, 2);
2191
2374
  // ── Step 1: Find seed trends across all relevant graphs ──
@@ -2347,7 +2530,7 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
2347
2530
  _presentation_hint: 'Present as a brainstorm map. Center: the query. First ring: seed trends. Second ring: adjacent territories. Highlight cross-trend brands and unexpected connections. Suggest follow-up explorations.',
2348
2531
  };
2349
2532
  // ── Query-level billing ──
2350
- chargeQuery({ queryTypeCode: 'brainstorm', apiKey, userId: resolveUserId(userId, uid), query, foddaRequest })
2533
+ chargeQuery({ queryTypeCode: 'brainstorm', apiKey, userId: resolveUserId(userId, uid), query, foddaRequest, spt: sptCtx?.token })
2351
2534
  .catch(e => console.error('[brainstorm] chargeQuery failed:', e.message));
2352
2535
  return { content: [{ type: 'text', text: JSON.stringify(brainstormMap, null, 2) }] };
2353
2536
  }
@@ -2358,7 +2541,7 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
2358
2541
  }
2359
2542
  });
2360
2543
  // --- generate_visual ---
2361
- server.tool('generate_visual', 'Generate a branded SVG data visualization from structured insight data. Use after research to create presentation-ready visuals. Available chart types: "cultural_shifts" (From→To transitions), "competitive_compass" (brands on 2 axes), "trend_constellation" (network of related trends), "implication_ladder" (Signal→Trend→So What→Do What), "innovation_pathway" (Now→Near-Term→Future), "opportunity_map" (2×2 white space analysis). Returns inline SVG that renders directly in the chat.', {
2544
+ server.tool('generate_visual', 'Create a presentation-ready data visualization from research findings. Available chart types: "cultural_shifts" (From→To transitions), "competitive_compass" (brands on 2 axes), "trend_constellation" (network of related trends), "implication_ladder" (Signal→Trend→So What→Do What), "innovation_pathway" (Now→Near-Term→Future), "opportunity_map" (2×2 white space analysis). Returns a branded SVG that renders directly in the chat.', {
2362
2545
  chart_type: z.enum(['cultural_shifts', 'competitive_compass', 'trend_constellation', 'implication_ladder', 'innovation_pathway', 'opportunity_map']).describe('The type of visualization to generate'),
2363
2546
  data: z.string().describe('JSON string containing the chart data. Structure depends on chart_type. cultural_shifts: {shifts:[{from,to}]}. competitive_compass: {brands:[{name,x,y}], axes:{left,right,top,bottom}}. trend_constellation: {trends:[{name,x,y}], connections:[{from,to,strength}]}. implication_ladder: {signal,trend,so_what,do_what}. innovation_pathway: {now,near_term,future}. opportunity_map: {items:[{name,consumer_desire,market_activity}]}'),
2364
2547
  }, { title: 'Generate Visual', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ chart_type, data }) => {
@@ -2417,7 +2600,7 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
2417
2600
  .describe('topic_research for sector trends, brand_intelligence for competitive tracking'),
2418
2601
  brands: z.array(z.string()).optional()
2419
2602
  .describe('For brand_intelligence: brand names to track (e.g., ["Nike", "Patagonia"])'),
2420
- }, { title: 'Manage Scheduled Reports', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, async ({ action, query, email, slack_webhook, graphs, schedule_id, cadence, timezone, report_type, brands }) => {
2603
+ }, { title: 'Manage Scheduled Reports', readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }, async ({ action, query, email, slack_webhook, graphs, schedule_id, cadence, timezone, report_type, brands }) => {
2421
2604
  try {
2422
2605
  if (action === 'create') {
2423
2606
  if (!query)
@@ -2487,7 +2670,7 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
2487
2670
  }
2488
2671
  });
2489
2672
  // --- read_url ---
2490
- server.tool('read_url', 'Extract clean text content from any URL. Use this when a user shares a link (competitor site, news article, client brief, trend report) and wants to cross-reference it against Fodda knowledge graphs. Returns structured text ready for analysis. Costs 15 API calls.', {
2673
+ server.tool('read_url', 'Extract clean text content from any URL. Use this when a user shares a link (competitor site, news article, client brief, trend report) and wants to cross-reference it against Fodda knowledge graphs. Returns structured text ready for analysis. Uses 15 tokens ($7.50 via SPT).', {
2491
2674
  url: z.string().describe('The URL to read and extract content from'),
2492
2675
  userId: z.string().optional().describe('Optional user identifier for usage tracking.')
2493
2676
  }, { title: 'Read URL Content', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, async ({ url, userId: uid }) => {
@@ -2507,7 +2690,7 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
2507
2690
  return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'Could not extract content from this URL. It may be behind a login or paywall.' }) }] };
2508
2691
  }
2509
2692
  // ── Query-level billing ──
2510
- chargeQuery({ queryTypeCode: 'url_as_prompt', apiKey, userId: resolveUserId(userId, uid), query: url, foddaRequest })
2693
+ chargeQuery({ queryTypeCode: 'url_as_prompt', apiKey, userId: resolveUserId(userId, uid), query: url, foddaRequest, spt: sptCtx?.token })
2511
2694
  .catch(e => console.error('[read_url] chargeQuery failed:', e.message));
2512
2695
  return {
2513
2696
  content: [{
@@ -2535,7 +2718,7 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
2535
2718
  // Uses the fodda-researcher agent with 5 skill instruction files.
2536
2719
  // Flow: Pre-fetch graph data → Build skill-loaded system instruction →
2537
2720
  // Call Gemini directly via waverunnerRequest → Stream progress via sendLoggingMessage.
2538
- server.tool('deep_research_topic', 'Launch an autonomous Deep Research session that combines Fodda knowledge graph intelligence with live web research to produce a comprehensive editorial-quality report. The Research Agent plans its own strategy, searches multiple graphs, validates with institutional data, and synthesizes into a narrative brief with inline source citations. Use for complex, multi-faceted questions that need both curated expert intelligence AND current web context — e.g., strategic briefings, market landscape reports, competitive deep dives. Depth: "light" (20 API calls, faster single-pass) or "heavy" (30 API calls, comprehensive multi-pass with validation).', {
2721
+ server.tool('deep_research_topic', 'Launch an autonomous Deep Research session that combines Fodda knowledge graph intelligence with live web research to produce a comprehensive editorial-quality report. The Research Agent plans its own strategy, searches multiple graphs, validates with institutional data, and synthesizes into a narrative brief with inline source citations. Use for complex, multi-faceted questions that need both curated expert intelligence AND current web context — e.g., strategic briefings, market landscape reports, competitive deep dives. Depth: "light" (20 API calls, faster single-pass) or "heavy" (30 API calls, comprehensive multi-pass with validation). Automatically includes earnings-call intelligence and macro/supplemental data when the topic warrants it (public companies, sectors, economic conditions). You do not need to call the earnings or supplemental tools separately before or after.', {
2539
2722
  query: z.string().describe('The research query/topic'),
2540
2723
  graphId: z.string().optional().describe('Optional specific graph ID to limit the research to'),
2541
2724
  depth: z.enum(['light', 'heavy']).optional().describe('Research depth: "light" for faster single-pass (20 API calls), "heavy" for comprehensive multi-pass (30 API calls). Defaults to "light".'),
@@ -2547,6 +2730,10 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
2547
2730
  const queryTypeCode = isHeavy ? 'deep_research_heavy' : 'deep_research_light';
2548
2731
  const maxGraphs = isHeavy ? 15 : 8;
2549
2732
  const startTime = Date.now();
2733
+ // ── SPT pre-run coverage: refuse before kicking off the (long, expensive) job ──
2734
+ const researchGuard = sptGuard(queryTypeCode);
2735
+ if (researchGuard)
2736
+ return researchGuard;
2550
2737
  try {
2551
2738
  // Log query to Questions table (fire-and-forget, before cache)
2552
2739
  logUserQuery(query, 'deep_research');
@@ -2556,15 +2743,40 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
2556
2743
  data: `📋 Phase 1/5: Planning research approach for "${query.slice(0, 80)}"...`,
2557
2744
  });
2558
2745
  console.error(`[deep_research_topic] Starting ${isHeavy ? 'heavy' : 'light'} research: "${query}"`);
2559
- // Determine relevant graphs
2560
- const relevantGraphs = graphId
2561
- ? [{ graph: { graph_id: graphId } }]
2562
- : getRelevantGraphs(query).slice(0, maxGraphs);
2563
- const graphIds = relevantGraphs.map(g => g.graph.graph_id);
2746
+ // Determine relevant sources — unified routing: graph selection is
2747
+ // getRelevantGraphs() unchanged; earnings/supplemental are appended
2748
+ // as additive extras that never displace graph slots.
2749
+ const sourceCandidates = graphId ? [] : getRelevantSources(query);
2750
+ const graphCandidates = sourceCandidates
2751
+ .filter((c) => c.kind === 'graph')
2752
+ .slice(0, maxGraphs);
2753
+ const graphIds = graphId ? [graphId] : graphCandidates.map(c => c.graphId);
2754
+ const earningsCandidate = sourceCandidates.find((c) => c.kind === 'earnings');
2755
+ const supplementalCandidates = sourceCandidates.filter((c) => c.kind === 'supplemental');
2756
+ // Routing visibility: every candidate selected, with why.
2757
+ // Supplemental is async-only (job + poll) — v1 notes the candidate
2758
+ // instead of fetching, pointing at the targeted tool.
2759
+ const sourcePlan = [
2760
+ ...(graphId
2761
+ ? [{ kind: 'graph', id: graphId, reason: 'explicitly requested via graphId' }]
2762
+ : graphCandidates.map(c => ({ kind: 'graph', id: c.graphId, reason: c.reason }))),
2763
+ ...(earningsCandidate ? [{
2764
+ kind: 'earnings',
2765
+ ...(earningsCandidate.ticker ? { ticker: earningsCandidate.ticker } : {}),
2766
+ ...(earningsCandidate.brand ? { brand: earningsCandidate.brand } : {}),
2767
+ ...(earningsCandidate.sector ? { sector: earningsCandidate.sector } : {}),
2768
+ reason: earningsCandidate.reason,
2769
+ }] : []),
2770
+ ...supplementalCandidates.map(c => ({
2771
+ kind: 'supplemental',
2772
+ category: c.category,
2773
+ reason: `${c.reason} — not auto-fetched in v1 (async source); targeted tool available: get_supplemental_context`,
2774
+ })),
2775
+ ];
2564
2776
  // ── Phase 2: Searching Fodda Knowledge Graphs ──
2565
2777
  await server.sendLoggingMessage({
2566
2778
  level: 'info',
2567
- data: `🔍 Phase 2/5: Searching ${graphIds.length} knowledge graph${graphIds.length !== 1 ? 's' : ''}...`,
2779
+ data: `🔍 Phase 2/5: Searching ${graphIds.length} knowledge graph${graphIds.length !== 1 ? 's' : ''}${earningsCandidate ? ' + earnings intelligence' : ''}...`,
2568
2780
  });
2569
2781
  // Pre-fetch graph data in parallel
2570
2782
  const graphSearchPromises = graphIds.map(async (gid) => {
@@ -2579,7 +2791,33 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
2579
2791
  return { graphId: gid, rows: [], evidence: [] };
2580
2792
  }
2581
2793
  });
2582
- const graphResults = await Promise.all(graphSearchPromises);
2794
+ // Earnings candidate: same endpoint as get_earnings_intelligence,
2795
+ // fetched in parallel with graph searches. Failure is non-fatal.
2796
+ const earningsPromise = earningsCandidate
2797
+ ? (async () => {
2798
+ try {
2799
+ const params = new URLSearchParams();
2800
+ if (earningsCandidate.ticker)
2801
+ params.set('ticker', earningsCandidate.ticker);
2802
+ if (earningsCandidate.brand)
2803
+ params.set('brand', earningsCandidate.brand);
2804
+ if (earningsCandidate.sector)
2805
+ params.set('sector', earningsCandidate.sector);
2806
+ if (earningsCandidate.search)
2807
+ params.set('search', earningsCandidate.search);
2808
+ params.set('limit', '10');
2809
+ return await foddaRequest('GET', `/v1/supplemental/earnings/snapshot?${params.toString()}`, apiKey, resolvedUserId);
2810
+ }
2811
+ catch (err) {
2812
+ console.error(`[deep_research_topic] Earnings snapshot failed (non-fatal): ${err?.message}`);
2813
+ return null;
2814
+ }
2815
+ })()
2816
+ : Promise.resolve(null);
2817
+ const [graphResults, earningsData] = await Promise.all([
2818
+ Promise.all(graphSearchPromises),
2819
+ earningsPromise,
2820
+ ]);
2583
2821
  const totalTrends = graphResults.reduce((sum, g) => sum + g.rows.length, 0);
2584
2822
  const totalEvidence = graphResults.reduce((sum, g) => sum + g.evidence.length, 0);
2585
2823
  const activeGraphs = graphResults.filter(g => g.rows.length > 0);
@@ -2633,6 +2871,8 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
2633
2871
  totalTrends,
2634
2872
  totalEvidence,
2635
2873
  focusGraphId: graphId,
2874
+ // Distinct earnings block — attributed by source type at synthesis
2875
+ ...(earningsData ? { earningsResults: JSON.stringify(earningsData).substring(0, 15000) } : {}),
2636
2876
  };
2637
2877
  // Build skill-loaded system instruction
2638
2878
  const systemInstruction = buildResearcherInstruction(query, graphContext);
@@ -2724,16 +2964,20 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
2724
2964
  return;
2725
2965
  }
2726
2966
  const durationSec = ((Date.now() - startTime) / 1000).toFixed(1);
2727
- chargeQuery({
2728
- queryTypeCode,
2729
- apiKey,
2730
- userId: resolvedUserId,
2731
- query,
2732
- graphsSearched: graphIds,
2733
- foddaRequest,
2734
- }).catch(e => console.error('[deep_research_topic] chargeQuery failed:', e.message));
2967
+ // ── Settlement gates delivery for SPT: only mark COMPLETE once the charge succeeds. ──
2968
+ if (sptCtx) {
2969
+ const r = await chargeQuery({ queryTypeCode, apiKey, userId: resolvedUserId, query, graphsSearched: graphIds, foddaRequest, spt: sptCtx.token });
2970
+ if (!r.charged) {
2971
+ activeResearchJobs.set(jobId, { status: 'FAILED', error: r.error || 'Payment could not be completed; report withheld.' });
2972
+ return;
2973
+ }
2974
+ }
2975
+ else {
2976
+ chargeQuery({ queryTypeCode, apiKey, userId: resolvedUserId, query, graphsSearched: graphIds, foddaRequest })
2977
+ .catch(e => console.error('[deep_research_topic] chargeQuery failed:', e.message));
2978
+ }
2735
2979
  const header = [
2736
- `_Research by Fodda Research Agent • ${activeGraphs.length} graph${activeGraphs.length !== 1 ? 's' : ''} searched • ${totalTrends} trends analyzed • ${durationSec}s_`,
2980
+ `_Research by Fodda Research Agent • ${activeGraphs.length} graph${activeGraphs.length !== 1 ? 's' : ''} searched • ${totalTrends} trends analyzed${earningsData ? ' • earnings intelligence included' : ''} • ${durationSec}s_`,
2737
2981
  '',
2738
2982
  ].join('\n');
2739
2983
  activeResearchJobs.set(jobId, { status: 'COMPLETE', result: header + reportText });
@@ -2746,7 +2990,7 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
2746
2990
  return {
2747
2991
  content: [{
2748
2992
  type: 'text',
2749
- text: `Deep research job started! The agent is searching the graph and the live web. Job ID: ${jobId}\n\nIMPORTANT: You must use the check_research_status tool with this Job ID to poll the status of the job and retrieve the report.`
2993
+ text: `Deep research job started! The agent is searching the graph and the live web. Job ID: ${jobId}\n\nsource_plan (sources the router selected and why — earnings/supplemental are included automatically when relevant, so no separate earnings or supplemental calls are needed):\n${JSON.stringify(sourcePlan, null, 2)}\n\nIMPORTANT: You must use the check_research_status tool with this Job ID to poll the status of the job and retrieve the report.`
2750
2994
  }]
2751
2995
  };
2752
2996
  }
@@ -2759,7 +3003,7 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
2759
3003
  }
2760
3004
  });
2761
3005
  // --- check_research_status ---
2762
- server.tool('check_research_status', 'Check the status of a long-running deep research job. If complete, this tool returns the final report. You MUST poll this periodically until the status is COMPLETE or FAILED.', {
3006
+ server.tool('check_research_status', 'Check if deep research is complete and retrieve the final report. Call this after deep_research_topic — poll every 10 seconds until status is COMPLETE or FAILED.', {
2763
3007
  job_id: z.string().describe('The Job ID returned by deep_research_topic'),
2764
3008
  }, { title: 'Check Research Status', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ job_id }) => {
2765
3009
  const job = activeResearchJobs.get(job_id);
@@ -2780,7 +3024,7 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
2780
3024
  return { isError: true, content: [{ type: 'text', text: `Unknown status for job ${job_id}` }] };
2781
3025
  });
2782
3026
  // --- consult_analyst ---
2783
- server.tool('consult_analyst', 'Consult a named Synthetic Analyst who answers in their expert voice using their curated knowledge graph. Each analyst has a unique methodology, domain expertise, and analytical lens that produces insights distinct from generic search or standard graph queries. Use when the user asks to talk to or consult a specific expert, or when you need a specialist perspective on culture, strategy, or innovation topics. Call list_analysts first to discover available analyst_id values. Responses may include a coverage status (in/adjacent/out), source attribution, and referrals to other expert graphs. Referrals MUST be presented in third-person platform voice (not the expert\'s voice) with an offer to query the referred graph.', {
3027
+ server.tool('consult_analyst', 'Consult a named Synthetic Analyst who answers in their expert voice using their curated knowledge graph. Each analyst has a unique methodology, domain expertise, and analytical lens that produces insights distinct from generic search or standard graph queries. Use when the user asks to talk to or consult a specific expert, or when you need a specialist perspective on culture, strategy, or innovation topics. Call list_analysts first to discover available analyst_id values. Responses may include a coverage status (in/adjacent/out), source attribution, and referrals to other expert graphs. Referrals MUST be presented in third-person platform voice (not the expert\'s voice) with an offer to query the referred graph. The analyst researches on your behalf: they can search Fodda\'s graphs, earnings intelligence, and supplemental data mid-consultation, and may refer or consult other analysts. Their research reads bill to you at standard rates ($0.50/call) and are itemized in `sources_used`.', {
2784
3028
  analyst_id: z.string().describe("The analyst ID (e.g., 'ben-dietz-sic')"),
2785
3029
  query: z.string().describe("The question or topic to discuss with the analyst"),
2786
3030
  company: z.string().optional().describe("Optional company name or stock ticker (e.g., 'Tesla' or 'TSLA') to bind the analyst to a specific brand context."),
@@ -2799,6 +3043,10 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
2799
3043
  ? result.result
2800
3044
  : (typeof result.report === 'string' ? result.report : JSON.stringify(result, null, 2));
2801
3045
  const parts = [reportText];
3046
+ // Surface server-side timing for observability
3047
+ if (result.timing_ms != null) {
3048
+ parts.push(`\n--- TIMING: ${result.timing_ms}ms server-side ---`);
3049
+ }
2802
3050
  // --- Structured envelope fields (Phase 2 Digital Twin) ---
2803
3051
  if (result.coverage) {
2804
3052
  parts.push(`\n--- COVERAGE: ${result.coverage} ---`);
@@ -2814,12 +3062,23 @@ export async function createServer(apiKey, userId, foddaRequest, waverunnerReque
2814
3062
  if (result.speaker_note) {
2815
3063
  parts.push(`--- SPEAKER NOTE: ${result.speaker_note} ---`);
2816
3064
  }
3065
+ const consultWithheld = await settleOrWithhold({ queryTypeCode: 'expert_agent', apiKey, userId: resolveUserId(userId, uid), query }, 'consult_analyst');
3066
+ if (consultWithheld)
3067
+ return consultWithheld;
2817
3068
  return { content: [{ type: 'text', text: parts.join('\n') }] };
2818
3069
  }
2819
3070
  catch (err) {
2820
3071
  const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
2821
3072
  if (trialResult)
2822
3073
  return trialResult;
3074
+ // Surface timeout explicitly so clients get actionable guidance
3075
+ if (err.code === 'ECONNABORTED' || err.message?.includes('timeout')) {
3076
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({
3077
+ error: `Analyst consultation timed out (60s). The upstream API is processing a complex query with tool calls. Retry in a moment, or use search_graph / get_expert_intelligence for faster results.`,
3078
+ analyst_id,
3079
+ timeout: true
3080
+ }) }] };
3081
+ }
2823
3082
  const msg = err.response?.data?.error?.message || err.response?.data?.message || err.message;
2824
3083
  return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
2825
3084
  }