fodda-mcp 1.7.0 → 1.31.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.
Files changed (106) hide show
  1. package/LICENSE +31 -0
  2. package/README.md +184 -24
  3. package/dist/a2aHandler.d.ts +20 -0
  4. package/dist/a2aHandler.d.ts.map +1 -0
  5. package/dist/a2aHandler.js +343 -0
  6. package/dist/a2aHandler.js.map +1 -0
  7. package/dist/agents/fodda-researcher/index.d.ts +38 -0
  8. package/dist/agents/fodda-researcher/index.d.ts.map +1 -0
  9. package/dist/agents/fodda-researcher/index.js +81 -0
  10. package/dist/agents/fodda-researcher/index.js.map +1 -0
  11. package/dist/agents/fodda-researcher/skills.d.ts +16 -0
  12. package/dist/agents/fodda-researcher/skills.d.ts.map +1 -0
  13. package/dist/agents/fodda-researcher/skills.js +163 -0
  14. package/dist/agents/fodda-researcher/skills.js.map +1 -0
  15. package/dist/brandTemplate.d.ts +14 -0
  16. package/dist/brandTemplate.d.ts.map +1 -0
  17. package/dist/brandTemplate.js +742 -0
  18. package/dist/brandTemplate.js.map +1 -0
  19. package/dist/catalogCache.d.ts +207 -0
  20. package/dist/catalogCache.d.ts.map +1 -0
  21. package/dist/catalogCache.js +837 -0
  22. package/dist/catalogCache.js.map +1 -0
  23. package/dist/editorialFill.d.ts +44 -0
  24. package/dist/editorialFill.d.ts.map +1 -0
  25. package/dist/editorialFill.js +377 -0
  26. package/dist/editorialFill.js.map +1 -0
  27. package/dist/enrichment.d.ts +74 -0
  28. package/dist/enrichment.d.ts.map +1 -0
  29. package/dist/enrichment.js +211 -0
  30. package/dist/enrichment.js.map +1 -0
  31. package/dist/errorHandling.d.ts +52 -0
  32. package/dist/errorHandling.d.ts.map +1 -0
  33. package/dist/errorHandling.js +338 -0
  34. package/dist/errorHandling.js.map +1 -0
  35. package/dist/index.d.ts +0 -1
  36. package/dist/index.js +672 -577
  37. package/dist/index.js.map +1 -1
  38. package/dist/pricingCache.d.ts +111 -0
  39. package/dist/pricingCache.d.ts.map +1 -0
  40. package/dist/pricingCache.js +454 -0
  41. package/dist/pricingCache.js.map +1 -0
  42. package/dist/queryCache.d.ts +25 -0
  43. package/dist/queryCache.d.ts.map +1 -0
  44. package/dist/queryCache.js +195 -0
  45. package/dist/queryCache.js.map +1 -0
  46. package/dist/searchTemplate.d.ts +16 -0
  47. package/dist/searchTemplate.d.ts.map +1 -0
  48. package/dist/searchTemplate.js +318 -0
  49. package/dist/searchTemplate.js.map +1 -0
  50. package/dist/sessionTracker.d.ts +39 -0
  51. package/dist/sessionTracker.d.ts.map +1 -0
  52. package/dist/sessionTracker.js +187 -0
  53. package/dist/sessionTracker.js.map +1 -0
  54. package/dist/skillClient.d.ts +142 -0
  55. package/dist/skillClient.d.ts.map +1 -0
  56. package/dist/skillClient.js +256 -0
  57. package/dist/skillClient.js.map +1 -0
  58. package/dist/svgVisuals.d.ts +77 -0
  59. package/dist/svgVisuals.d.ts.map +1 -0
  60. package/dist/svgVisuals.js +323 -0
  61. package/dist/svgVisuals.js.map +1 -0
  62. package/dist/systemPrompt.d.ts +26 -0
  63. package/dist/systemPrompt.d.ts.map +1 -0
  64. package/dist/systemPrompt.js +574 -0
  65. package/dist/systemPrompt.js.map +1 -0
  66. package/dist/test-waverunner-response.d.ts +2 -0
  67. package/dist/test-waverunner-response.d.ts.map +1 -0
  68. package/dist/test-waverunner-response.js +20 -0
  69. package/dist/test-waverunner-response.js.map +1 -0
  70. package/dist/test_analyst.d.ts +2 -0
  71. package/dist/test_analyst.d.ts.map +1 -0
  72. package/dist/test_analyst.js +39 -0
  73. package/dist/test_analyst.js.map +1 -0
  74. package/dist/test_analyst_local.d.ts +2 -0
  75. package/dist/test_analyst_local.d.ts.map +1 -0
  76. package/dist/test_analyst_local.js +51 -0
  77. package/dist/test_analyst_local.js.map +1 -0
  78. package/dist/test_live_mcp.js +1 -1
  79. package/dist/test_skills.d.ts +9 -0
  80. package/dist/test_skills.d.ts.map +1 -0
  81. package/dist/test_skills.js +267 -0
  82. package/dist/test_skills.js.map +1 -0
  83. package/dist/toolHandlers.d.ts +15 -0
  84. package/dist/toolHandlers.d.ts.map +1 -0
  85. package/dist/toolHandlers.js +2986 -0
  86. package/dist/toolHandlers.js.map +1 -0
  87. package/dist/tools.d.ts +78 -3
  88. package/dist/tools.d.ts.map +1 -1
  89. package/dist/tools.js +85 -292
  90. package/dist/tools.js.map +1 -1
  91. package/dist/trialTracker.d.ts +58 -0
  92. package/dist/trialTracker.d.ts.map +1 -0
  93. package/dist/trialTracker.js +179 -0
  94. package/dist/trialTracker.js.map +1 -0
  95. package/dist/types.d.ts +1 -0
  96. package/dist/types.d.ts.map +1 -1
  97. package/dist/verify_hmac.js +1 -1
  98. package/dist/verify_hmac.js.map +1 -1
  99. package/dist/verify_tools_endpoint.js +5 -5
  100. package/dist/verify_tools_endpoint.js.map +1 -1
  101. package/dist/widgetShell.d.ts +15 -0
  102. package/dist/widgetShell.d.ts.map +1 -0
  103. package/dist/widgetShell.js +151 -0
  104. package/dist/widgetShell.js.map +1 -0
  105. package/package.json +8 -4
  106. package/server.json +13 -4
@@ -0,0 +1,2986 @@
1
+ /**
2
+ * Tool Handlers — MCP server factory with all tool registrations.
3
+ *
4
+ * Extracted from index.ts to reduce monolith size.
5
+ * Contains createServer() which registers all 30+ MCP tools.
6
+ */
7
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
8
+ import { ListResourcesRequestSchema, ListPromptsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
9
+ import { z } from 'zod';
10
+ import { GoogleGenAI } from '@google/genai';
11
+ import axios from 'axios';
12
+ import crypto from 'crypto';
13
+ import { buildDynamicPromptSections, getDomainGraphIds, getGraphs, getLiveGraphs, buildDisplayName, getRelevantGraphs, getRelevantSources, getEnabledSkillConfigs, getSkillGraphs, getAnalysts } from './catalogCache.js';
14
+ import { renderBrandWidget } from './brandTemplate.js';
15
+ import { renderSearchWidget } from './searchTemplate.js';
16
+ import { FODDA_COMPONENT_GUIDE, getShellTemplate } from './widgetShell.js';
17
+ import { MCP_SERVER_VERSION } from './tools.js';
18
+ import { buildSystemPrompt, BRAND_INTELLIGENCE_RENDERING_SPEC, FODDA_WIDGET_DESIGN_BRIEF } from './systemPrompt.js';
19
+ import { computeLifecycle, computeMomentum, isFastMover, enrichEvidence, GRAPH_BADGES, getFoddaTheme, getSupplementalTheme } from './enrichment.js';
20
+ import { handleAccessError, handleTrialCreditExhaustion, classifyAccessError } from './errorHandling.js';
21
+ import { chargeQuery, getToolCostSummary } from './pricingCache.js';
22
+ import { callOutputSkills, buildSkillInput, discoverSkillTools, executeSkillTool, mapSkillError } from './skillClient.js';
23
+ import { createSessionTracker, postToSlack } from './sessionTracker.js';
24
+ import { buildResearcherInstruction } from './agents/fodda-researcher/index.js';
25
+ // ---------------------------------------------------------------------------
26
+ // Render instructions — embedded in tool responses for LLM clients that
27
+ // don't receive MCP server-level `instructions` (e.g. Claude.ai).
28
+ // ---------------------------------------------------------------------------
29
+ const RENDER_SPEC_VERSION = '1.0';
30
+ function buildRenderInstructions(opts) {
31
+ const rules = [
32
+ 'Attribute findings to their source graph by name using graphName. Never say "the Fodda graph." Fodda is the platform — the graphs are created by named experts.',
33
+ ];
34
+ if (opts.hasEvidence) {
35
+ rules.push('Use formatted_citation from evidence items as inline markdown links. Never present a claim without its source link.');
36
+ }
37
+ if (opts.hasPrompts) {
38
+ rules.push('Surface suggested_next_prompts to the user as numbered follow-up suggestions.');
39
+ }
40
+ rules.push('Each trend result has a suggested_drill_down field containing a ready-to-use follow-up prompt. After presenting each trend, show its drill-down as a clickable suggestion (e.g. "→ Tell me more about X"). This lets the user explore individual trends without typing.');
41
+ if (opts.hasWidget) {
42
+ rules.push('A separate content block starting with "── WIDGET HTML ──" may follow this JSON payload. It contains a pre-rendered Fodda visualization. If your client supports HTML rendering (show_widget, visualize:show_widget, artifacts), pass that HTML verbatim. Do not rewrite or restyle.');
43
+ }
44
+ rules.push('Keep responses clean and structured. Lead with a sharp editorial claim, not a summary of data methodology.');
45
+ const instructions = {
46
+ _render_spec_version: RENDER_SPEC_VERSION,
47
+ rules,
48
+ };
49
+ // Graph-level link templates (populated when catalog has webpage_url)
50
+ if (opts.graphWebpageUrls && Object.keys(opts.graphWebpageUrls).length > 0) {
51
+ instructions._source_links = opts.graphWebpageUrls;
52
+ }
53
+ return instructions;
54
+ }
55
+ /**
56
+ * Append a low-credit warning to the response data if API calls are running low.
57
+ * Utilizes new dynamic Stripe links and upsell data provided by the Fodda API.
58
+ *
59
+ * Also surfaces upstream X-Usage-* header warnings (approaching-limit, overage-active)
60
+ * injected by foddaRequest() as _upstream_usage.
61
+ */
62
+ function appendUsageWarning(data, userEmail) {
63
+ // ── Upstream header-based warnings (X-Usage-Warning / X-Usage-Percent / X-Usage-Overage-Tokens) ──
64
+ if (data?._upstream_usage) {
65
+ const u = data._upstream_usage;
66
+ if (u.warning === 'approaching-limit' && u.percent) {
67
+ data._usage_status = `⚠️ You've used ${u.percent}% of your monthly API calls. Consider upgrading or adding a payment method to avoid interruption.`;
68
+ }
69
+ else if (u.warning === 'overage-active') {
70
+ data._usage_status = u.overage_tokens
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
+ }
74
+ }
75
+ if (!data?.usage)
76
+ return;
77
+ const remaining = data.usage.remaining ?? data.usage.credits_remaining;
78
+ if (remaining === undefined || remaining < 0)
79
+ return;
80
+ const threshold = 15;
81
+ if (remaining >= threshold)
82
+ return;
83
+ const noun = remaining === 1 ? 'API call' : 'API calls';
84
+ const upsell = data.usage.upsell || data.upsell || (data._account?.upsell);
85
+ const stripeLink = data.usage.stripeLink || (data._account?.stripe_link);
86
+ const price = data.usage.monthlyPriceUSD || (data._account?.monthly_price_usd);
87
+ // Build portal upgrade URL with pre-filled email
88
+ const portalParams = new URLSearchParams({ action: 'upgrade' });
89
+ if (userEmail && userEmail.includes('@'))
90
+ portalParams.set('email', userEmail);
91
+ const portalUrl = `${API_BASE_URL.replace('api.', 'app.')}/portal?${portalParams.toString()}`;
92
+ {
93
+ let msg = `\u26a0\ufe0f You have ${remaining} ${noun} remaining this month.`;
94
+ if (upsell) {
95
+ msg += ` You can get 100 more API calls for $${upsell.price || '50'} right now: ${upsell.link || portalUrl}`;
96
+ }
97
+ else if (stripeLink) {
98
+ msg += ` You can top up your API calls here: ${stripeLink}`;
99
+ }
100
+ else {
101
+ msg += ` You can add more at ${portalUrl}, or your balance resets next month.`;
102
+ }
103
+ data._credit_warning = msg;
104
+ }
105
+ }
106
+ /** Collect graph webpage URLs from catalog for graphs present in results */
107
+ function collectGraphWebpageUrls(graphIds) {
108
+ const urls = {};
109
+ const allGraphs = getGraphs();
110
+ for (const gid of graphIds) {
111
+ const g = allGraphs.find(cat => cat.graph_id === gid);
112
+ // webpage_url will become available once the API surfaces it from Airtable
113
+ if (g && g.webpage_url) {
114
+ urls[gid] = g.webpage_url;
115
+ }
116
+ }
117
+ return urls;
118
+ }
119
+ const API_BASE_URL = process.env.FODDA_API_URL || 'https://api.fodda.ai';
120
+ const GRAPH_ID_DESC = "The graph ID. Use list_graphs to see all options. 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', 'michaels-2026-creativity-trend-report', 'delta/the-connection-index'";
121
+ // ── P0 Security: Allowlist serializer for list_graphs ──
122
+ const GRAPH_LIST_ALLOWLIST = new Set([
123
+ 'graph_id', 'name', 'one_liner', 'description', 'curator',
124
+ 'domain', 'graph_type', 'trend_count', 'evidence_count',
125
+ 'status', 'last_updated',
126
+ ]);
127
+ const SNAKE_TO_CAMEL = {
128
+ 'graph_id': 'graphId', 'one_liner': 'oneLiner', 'graph_type': 'graphType',
129
+ 'trend_count': 'trendCount', 'evidence_count': 'evidenceCount',
130
+ 'last_updated': 'lastUpdated',
131
+ };
132
+ // Strip internal routing guidance that may be baked into a description — either
133
+ // injected by us below or already present in the API/Airtable description field.
134
+ function stripRoutingInstruction(text) {
135
+ return text.replace(/\n*\[ROUTING INSTRUCTION:[\s\S]*?\]\s*$/g, '').trimEnd();
136
+ }
137
+ function serializeGraphForList(g) {
138
+ const out = {};
139
+ for (const key of GRAPH_LIST_ALLOWLIST) {
140
+ let val = g[key] ?? g[SNAKE_TO_CAMEL[key] || key];
141
+ if (val !== undefined && val !== null) {
142
+ // Defensive: never surface internal routing text in the public description.
143
+ if (key === 'description' && typeof val === 'string') {
144
+ val = stripRoutingInstruction(val);
145
+ }
146
+ out[key] = val;
147
+ }
148
+ }
149
+ return out;
150
+ }
151
+ const DEPRECATED_GRAPH_IDS = new Set(['waldo', 'psfk']);
152
+ /**
153
+ * Resolve the effective userId for API requests.
154
+ * Priority: session userId (from MCP URL, typically an email) > tool-provided userId.
155
+ * For authenticated users, the session email ALWAYS wins — the LLM-generated slug is ignored.
156
+ * For trial/anonymous users (session userId is 'anonymous' or empty), the tool-provided
157
+ * userId acts as a fingerprint for key-sharing detection.
158
+ */
159
+ function resolveUserId(sessionUserId, toolProvidedUid) {
160
+ // If session has a real identifier (email), always use it
161
+ if (sessionUserId && sessionUserId !== 'anonymous') {
162
+ return sessionUserId;
163
+ }
164
+ // For trial/anonymous: use tool-provided uid as fingerprint, fall back to 'anonymous'
165
+ return toolProvidedUid || sessionUserId || 'anonymous';
166
+ }
167
+ // ---------------------------------------------------------------------------
168
+ // createServer — builds and returns a fully-configured MCP server
169
+ // ---------------------------------------------------------------------------
170
+ const activeResearchJobs = new Map();
171
+ const activeSupplementalJobs = new Map();
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
+ };
201
+ // Fetch account profile for persona-aware framing (best-effort)
202
+ let accountProfile;
203
+ let sessionDisabledGraphs = new Set();
204
+ let sessionSkills = [];
205
+ let discoveredSkills = [];
206
+ try {
207
+ // H2: Race the /v1/graphs call against a 5s timeout so a slow upstream
208
+ // never blocks the MCP initialize handshake. Degrades gracefully —
209
+ // missing account profile means no persona-aware framing, but tools work.
210
+ const INIT_TIMEOUT_MS = 5000;
211
+ const graphsData = await Promise.race([
212
+ foddaRequest('GET', '/v1/graphs', apiKey, userId),
213
+ new Promise((resolve) => setTimeout(() => resolve(null), INIT_TIMEOUT_MS)),
214
+ ]);
215
+ if (graphsData?._account) {
216
+ accountProfile = graphsData._account;
217
+ console.error(`[persona] Account profile loaded: isProfessionalServices=${accountProfile.isProfessionalServices}, jobTitle=${accountProfile.jobTitle}, company=${accountProfile.companyName}`);
218
+ }
219
+ // Capture disabled graphs from the API response (added by API Agent)
220
+ if (Array.isArray(graphsData?.disabled_graphs)) {
221
+ sessionDisabledGraphs = new Set(graphsData.disabled_graphs);
222
+ console.error(`[skills] User has ${sessionDisabledGraphs.size} disabled graph(s)`);
223
+ }
224
+ // Resolve enabled skills for this session
225
+ // Strategy: extract skill configs from /v1/graphs response directly,
226
+ // then fall back to catalogCache. Skills route through the Core API
227
+ // now — no mcp_url or auth tokens needed.
228
+ const responseGraphs = graphsData?.graphs || [];
229
+ const responseSkills = responseGraphs
230
+ .filter((g) => g.graph_type === 'skill' && !g.disabled && (g.status === 'live' || g.status === 'beta'))
231
+ .map((g) => ({
232
+ id: g.graph_id,
233
+ name: g.name,
234
+ phase: (g.skill_phase === 'research' || g.skillPhase === 'research' ? 'research' : 'output'),
235
+ }));
236
+ // Use response-extracted skills if available, otherwise fall back to catalogCache
237
+ sessionSkills = responseSkills.length > 0
238
+ ? responseSkills
239
+ : getEnabledSkillConfigs(sessionDisabledGraphs);
240
+ if (sessionSkills.length > 0) {
241
+ console.error(`[skills] ${sessionSkills.length} skill(s) enabled: ${sessionSkills.map(s => s.name).join(', ')}`);
242
+ }
243
+ // Discover interactive tools for each enabled skill via Core API
244
+ // This calls GET /v1/skills/{skill_id}/tools for each skill in parallel
245
+ if (sessionSkills.length > 0) {
246
+ const discoveryResults = await Promise.allSettled(sessionSkills.map(s => discoverSkillTools(s.id, apiKey)));
247
+ for (const r of discoveryResults) {
248
+ if (r.status === 'fulfilled' && r.value) {
249
+ discoveredSkills.push(r.value);
250
+ }
251
+ }
252
+ if (discoveredSkills.length > 0) {
253
+ const totalTools = discoveredSkills.reduce((sum, s) => sum + s.tools.length, 0);
254
+ console.error(`[skills] Discovered ${totalTools} interactive tool(s) across ${discoveredSkills.length} skill(s): ${discoveredSkills.map(s => `${s.skill_name}(${s.tools.length})`).join(', ')}`);
255
+ }
256
+ }
257
+ }
258
+ catch (err) {
259
+ console.error('[persona] Failed to fetch account profile — proceeding without persona framing');
260
+ }
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.
264
+ const sessionTracker = createSessionTracker();
265
+ // Fire-and-forget: log the user's query text to the Questions table.
266
+ // Called at tool entry — BEFORE cache-eligible foddaRequest calls —
267
+ // so the question is captured even when the MCP query cache serves a hit.
268
+ function logUserQuery(query, interactionType, graphId) {
269
+ foddaRequest('POST', '/v1/log/question', apiKey, userId, {
270
+ question: query,
271
+ graphId: graphId || 'all',
272
+ interactionType,
273
+ source: 'mcp',
274
+ }).catch(() => { }); // Never block on logging failures
275
+ }
276
+ // Build skill metadata for system prompt — includes both output-phase and interactive skills
277
+ const skillPromptMeta = sessionSkills.map(s => {
278
+ const discovered = discoveredSkills.find(d => d.skill_id === s.id);
279
+ return {
280
+ id: s.id,
281
+ name: s.name,
282
+ interactiveTools: discovered?.tools.map(t => `${s.id}_${t.name}`) || [],
283
+ costPerCall: discovered?.cost_per_call ?? 2,
284
+ };
285
+ });
286
+ const server = new McpServer({
287
+ name: 'fodda_mcp',
288
+ version: MCP_SERVER_VERSION,
289
+ }, {
290
+ instructions: buildSystemPrompt(accountProfile, skillPromptMeta, entryId),
291
+ });
292
+ // Register empty capabilities and handlers to silence directory warnings
293
+ server.server.registerCapabilities({
294
+ resources: {},
295
+ prompts: {}
296
+ });
297
+ server.server.setRequestHandler(ListResourcesRequestSchema, async () => {
298
+ return { resources: [] };
299
+ });
300
+ server.server.setRequestHandler(ListPromptsRequestSchema, async () => {
301
+ return { prompts: [] };
302
+ });
303
+ // ── Register discovered interactive skill tools as MCP tools ──
304
+ for (const discovered of discoveredSkills) {
305
+ for (const tool of discovered.tools) {
306
+ const prefixedName = `${discovered.skill_id}_${tool.name}`;
307
+ const costNote = `(costs ${discovered.cost_per_call} API calls)`;
308
+ const description = `[${discovered.skill_name}] ${tool.description || tool.name} ${costNote}`;
309
+ // Build a Zod schema from the tool's inputSchema
310
+ // The inputSchema from the API is a JSON Schema object — we accept
311
+ // it as a free-form argument object and pass through to the API
312
+ server.tool(prefixedName, description, { arguments: z.record(z.string(), z.any()).optional().describe('Arguments for the skill tool. Check the tool description for expected parameters.') }, { title: `${discovered.skill_name}: ${tool.name}`, readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true }, async ({ arguments: toolArgs }) => {
313
+ try {
314
+ const { output, durationMs } = await executeSkillTool(discovered.skill_id, tool.name, toolArgs || {}, apiKey, userId);
315
+ return {
316
+ content: [{
317
+ type: 'text',
318
+ text: output || '(No output returned from skill)',
319
+ }],
320
+ };
321
+ }
322
+ catch (err) {
323
+ // Map known skill errors to user-friendly messages
324
+ const friendlyMsg = mapSkillError(err);
325
+ if (friendlyMsg) {
326
+ return {
327
+ isError: true,
328
+ content: [{ type: 'text', text: friendlyMsg }],
329
+ };
330
+ }
331
+ // Check for credit exhaustion
332
+ const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
333
+ if (trialResult)
334
+ return trialResult;
335
+ // Generic error
336
+ const msg = err.response?.data?.error?.message || err.message;
337
+ return {
338
+ isError: true,
339
+ content: [{ type: 'text', text: JSON.stringify({ error: msg }) }],
340
+ };
341
+ }
342
+ });
343
+ }
344
+ }
345
+ // --- get_my_account ---
346
+ server.tool('get_my_account', 'Check the current user\'s account status: API call balance, plan, enabled/disabled graphs, and profile info. Use when the user asks "how many API calls do I have?", "what plan am I on?", "what graphs can I access?", or similar account questions. Returns live data — not cached from session start.', {}, { title: 'Check Account Status', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async () => {
347
+ try {
348
+ // Fetch fresh account data from /v1/graphs (which returns _account)
349
+ const data = await foddaRequest('GET', '/v1/graphs', apiKey, userId);
350
+ const account = data?._account;
351
+ if (!account) {
352
+ return {
353
+ content: [{
354
+ type: 'text',
355
+ text: JSON.stringify({ note: 'Account information is not available. Visit app.fodda.ai to manage your account.' })
356
+ }]
357
+ };
358
+ }
359
+ // Format a clean, user-friendly response
360
+ // Note: API still returns tokens_remaining etc. — we read those fields
361
+ // but present them as "api_calls" to the user.
362
+ const status = {
363
+ plan: account.plan || 'Unknown',
364
+ api_calls_remaining: account.tokens_remaining ?? account.credits ?? 'unknown',
365
+ api_calls_total: account.tokens_total ?? account.monthlyQueryLimit ?? 'unknown',
366
+ };
367
+ // Flag overage status when tokens_remaining is negative (overage billing active)
368
+ if (typeof status.api_calls_remaining === 'number' && status.api_calls_remaining < 0) {
369
+ status.overage_active = true;
370
+ status.overage_tokens = Math.abs(status.api_calls_remaining);
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.`;
372
+ }
373
+ if (account.tokens_used !== undefined)
374
+ status.api_calls_used = account.tokens_used;
375
+ if (account.reset_date)
376
+ status.reset_date = account.reset_date;
377
+ if (account.graphs_enabled?.length) {
378
+ status.graphs_enabled_count = account.graphs_enabled.length;
379
+ status.graphs_enabled_note = 'Use list_graphs to see the full list';
380
+ }
381
+ if (account.graphs_disabled?.length)
382
+ status.graphs_disabled = account.graphs_disabled;
383
+ if (account.profile) {
384
+ status.profile = {};
385
+ if (account.profile.name && !/^rec[A-Za-z0-9]{14}$/.test(account.profile.name))
386
+ status.profile.name = account.profile.name;
387
+ if (account.profile.company)
388
+ status.profile.company = account.profile.company;
389
+ if (account.profile.jobTitle)
390
+ status.profile.job_title = account.profile.jobTitle;
391
+ }
392
+ status.manage_url = 'https://app.fodda.ai/account';
393
+ if (account.stripe_link)
394
+ status.stripe_link = account.stripe_link;
395
+ if (account.upsell && account.upsell.plan && account.upsell.price > 0) {
396
+ status.upgrade_offer = {
397
+ target: account.upsell.plan,
398
+ price: `$${account.upsell.price}`,
399
+ link: account.upsell.link,
400
+ action: `Upgrade to ${account.upsell.plan}`
401
+ };
402
+ }
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;
408
+ return {
409
+ content: [{
410
+ type: 'text',
411
+ text: JSON.stringify(status, null, 2)
412
+ }]
413
+ };
414
+ }
415
+ catch (err) {
416
+ const msg = err.response?.data?.error?.message || err.response?.data?.message || err.message;
417
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
418
+ }
419
+ });
420
+ // --- list_graphs ---
421
+ server.tool('list_graphs', 'List all knowledge graphs the user can access — IDs, descriptions, authors, sectors, signal counts. Use FIRST in any session to discover available sources before searching. Returns graph metadata needed for graphId parameters in other tools. Deprecated: waldo, psfk (use retail/tech/food/travel/fashion/beauty/sports instead).', { userId: z.string().optional().describe('Optional user identifier. Authenticated users are identified automatically via API key. For trial users, this helps track usage.') }, { title: 'List Knowledge Graphs', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ userId: uid }) => {
422
+ try {
423
+ const data = await foddaRequest('GET', '/v1/graphs', apiKey, resolveUserId(userId, uid));
424
+ // P0: Apply allowlist serializer — strips PII (owner_email) and CMS bloat fields,
425
+ // and sanitizes any routing text baked into description. Routing guidance is then
426
+ // exposed as a dedicated routing_hint field for LLM consumption, keeping the
427
+ // public-facing description clean (it must never contain internal routing text).
428
+ if (data && Array.isArray(data.graphs)) {
429
+ data.graphs = data.graphs.map((g) => {
430
+ const serialized = serializeGraphForList(g);
431
+ if (g.agent_prompt) {
432
+ serialized.routing_hint = g.agent_prompt;
433
+ }
434
+ return serialized;
435
+ });
436
+ }
437
+ // Profile nudge: if userContext is empty, append a nudge for Claude
438
+ const account = data?._account;
439
+ // Strip _account from list_graphs output (use get_my_account instead)
440
+ if (data)
441
+ delete data._account;
442
+ if (account && !account.userContext) {
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---`;
444
+ const jsonText = JSON.stringify(data, null, 2);
445
+ return { content: [{ type: 'text', text: jsonText + nudge }] };
446
+ }
447
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
448
+ }
449
+ catch (err) {
450
+ const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
451
+ if (trialResult)
452
+ return trialResult;
453
+ const msg = err.response?.data?.error?.message || err.response?.data?.message || err.message;
454
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
455
+ }
456
+ });
457
+ // --- list_analysts ---
458
+ server.tool('list_analysts', 'List available Synthetic Analysts — named expert personas grounded in specific knowledge graphs. Each analyst has a unique voice, methodology, and domain expertise that cannot be replicated by web search. Use when user asks to "talk to" or "consult" an expert, or when you need specialist depth on culture, strategy, or innovation topics.', { userId: z.string().optional().describe('Optional user identifier.') }, { title: 'List Synthetic Analysts', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ userId: uid }) => {
459
+ try {
460
+ const data = await foddaRequest('GET', '/v1/analysts', apiKey, resolveUserId(userId, uid));
461
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
462
+ }
463
+ catch (err) {
464
+ const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
465
+ if (trialResult)
466
+ return trialResult;
467
+ const msg = err.response?.data?.error?.message || err.response?.data?.message || err.message;
468
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
469
+ }
470
+ });
471
+ // --- search_graph ---
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.', {
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'"),
474
+ query: z.string().describe('The search query. Location terms are auto-detected and used to filter results geographically.'),
475
+ userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
476
+ limit: z.number().optional().describe('Maximum number of results (default 10, max 50)'),
477
+ use_semantic: z.boolean().optional().describe('Whether to use semantic search (default true)'),
478
+ include_evidence: z.boolean().optional().describe('If true, batch-fetch supporting evidence articles inline with results. Default: true.'),
479
+ skip_skills: z.boolean().optional().describe('If true, skip applying any enabled skills (Paralogy, Igloo, etc.) for this query only. Use when the user says "without skills", "skip Paralogy", or "just the raw results". Default: false.')
480
+ }, { title: 'Search Knowledge Graph', readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ graphId, query, userId: uid, limit, use_semantic, include_evidence, skip_skills }) => {
481
+ try {
482
+ // Log query to Questions table (fire-and-forget, before cache)
483
+ logUserQuery(query, 'search', graphId);
484
+ const effectiveLimit = Math.min(limit || 10, 50);
485
+ const body = {
486
+ query,
487
+ limit: effectiveLimit,
488
+ use_semantic: use_semantic !== false,
489
+ include_evidence: include_evidence ?? true,
490
+ };
491
+ // ── Supplemental data is deferred until we know results are relevant ──
492
+ let data;
493
+ // If no graphId or deprecated 'psfk', use smart 2-step routing
494
+ if (!graphId || graphId === 'psfk') {
495
+ // Step 1: Score query against graph metadata to find relevant graphs
496
+ const relevantGraphs = getRelevantGraphs(query);
497
+ const graphsToSearch = relevantGraphs.map(r => r.graph);
498
+ const perGraphLimit = Math.max(5, Math.ceil(effectiveLimit / Math.max(graphsToSearch.length, 1)));
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 })));
500
+ // Merge rows, deduplicate by trendId + near-duplicate name detection
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;
506
+ const seen = new Set();
507
+ const seenNames = []; // for near-duplicate check
508
+ const nameTokens = (name) => new Set(name.toLowerCase().replace(/[^a-z0-9\s]/g, '').split(/\s+/).filter(t => t.length > 2));
509
+ const isSemDuplicate = (nameA, nameB) => {
510
+ const tokA = nameTokens(nameA);
511
+ const tokB = nameTokens(nameB);
512
+ if (tokA.size === 0 || tokB.size === 0)
513
+ return false;
514
+ const overlap = [...tokA].filter(t => tokB.has(t)).length;
515
+ return overlap / Math.min(tokA.size, tokB.size) > 0.6;
516
+ };
517
+ for (let i = 0; i < results.length; i++) {
518
+ const r = results[i];
519
+ if (r.status !== 'fulfilled') {
520
+ if (!creditRejection && classifyAccessError(r.reason) === 'credits') {
521
+ creditRejection = r.reason;
522
+ }
523
+ continue;
524
+ }
525
+ const fulfilled = r;
526
+ const rows = Array.isArray(fulfilled.value) ? fulfilled.value : (fulfilled.value?.rows || []);
527
+ const graphMeta = relevantGraphs[i];
528
+ for (const row of rows) {
529
+ const key = row.trendId || row.node_id || row.trendName || `${row.name}_${row.signal_score}`;
530
+ if (seen.has(String(key)))
531
+ continue;
532
+ // Near-duplicate name check
533
+ const rowName = row.trendName || row.name || '';
534
+ if (rowName && seenNames.some(n => isSemDuplicate(n, rowName)))
535
+ continue;
536
+ seen.add(String(key));
537
+ if (rowName)
538
+ seenNames.push(rowName);
539
+ // Tag with source tier and label for editorial composition
540
+ if (graphMeta) {
541
+ const g = graphMeta.graph;
542
+ row.source_tier = graphMeta.graphTier;
543
+ row.source_label = g.graph_type === 'domain'
544
+ ? `${g.name} (PSFK Living)`
545
+ : g.graph_type === 'expert' && graphMeta.graphTier === 'living'
546
+ ? `${g.name} (Living Expert)`
547
+ : g.graph_type === 'expert'
548
+ ? `${g.name} (Expert)`
549
+ : `${g.name} (${g.company || 'Report'})`;
550
+ }
551
+ allRows.push(row);
552
+ }
553
+ }
554
+ allRows.sort((a, b) => {
555
+ const relA = a.relevance_score || a.semantic_score || a._score || 0;
556
+ const relB = b.relevance_score || b.semantic_score || b._score || 0;
557
+ // Primary: relevance score (includes evidence + freshness from API)
558
+ if (Math.abs(relB - relA) > 0.05)
559
+ return relB - relA;
560
+ // Tiebreaker: prefer more recent content
561
+ const daysA = a.freshnessDays || 999;
562
+ const daysB = b.freshnessDays || 999;
563
+ if (daysA !== daysB)
564
+ return daysA - daysB;
565
+ return (b.signal_score || 0) - (a.signal_score || 0);
566
+ });
567
+ // Diagnostic: check freshnessDays flow
568
+ console.error(`[search_graph] Freshness check (top 3):`, allRows.slice(0, 3).map(r => ({
569
+ name: (r.trendName || r.label || '?').slice(0, 30),
570
+ freshnessDays: r.freshnessDays,
571
+ updated_at: r.updated_at,
572
+ freshnessDate: r.freshnessDate,
573
+ lastSeen: r.lastSeen,
574
+ rel: r.relevance_score,
575
+ })));
576
+ // ── Quality-gated diversity reranking ──
577
+ // When one graph dominates >50% of top results, promote competitive
578
+ // results from underrepresented graphs. Never forces thin/low-quality
579
+ // results — only swaps when the alternative is within 15% of the
580
+ // dominant graph's weakest included result.
581
+ const diversified = (() => {
582
+ const candidate = allRows.slice(0, effectiveLimit);
583
+ if (candidate.length < 4 || graphsToSearch.length < 3)
584
+ return candidate;
585
+ const getGraphId = (r) => r.graphId || r._use_this_graphId || 'unknown';
586
+ const getScore = (r) => r.relevance_score || r.semantic_score || r._score || 0;
587
+ // Count per-graph representation in the top N
588
+ const graphCounts = {};
589
+ for (const row of candidate) {
590
+ const gid = getGraphId(row);
591
+ graphCounts[gid] = (graphCounts[gid] || 0) + 1;
592
+ }
593
+ // Find the dominant graph (if any holds >50%)
594
+ const dominantEntry = Object.entries(graphCounts)
595
+ .sort((a, b) => b[1] - a[1])[0];
596
+ if (!dominantEntry || dominantEntry[1] <= candidate.length * 0.5)
597
+ return candidate;
598
+ const [dominantGraphId, dominantCount] = dominantEntry;
599
+ const dominantCap = Math.max(2, Math.ceil(candidate.length * 0.4)); // allow up to 40%
600
+ if (dominantCount <= dominantCap)
601
+ return candidate;
602
+ // Find the weakest score from the dominant graph in the current results
603
+ const dominantScores = candidate
604
+ .filter(r => getGraphId(r) === dominantGraphId)
605
+ .map(r => getScore(r));
606
+ const dominantFloor = Math.min(...dominantScores);
607
+ // Quality threshold: alternatives must score within 15% of the dominant floor
608
+ const qualityThreshold = dominantFloor * 0.85;
609
+ // Collect competitive alternatives from the overflow pool
610
+ const overflow = allRows.slice(effectiveLimit);
611
+ const alternatives = overflow
612
+ .filter(r => getGraphId(r) !== dominantGraphId && getScore(r) >= qualityThreshold)
613
+ .sort((a, b) => getScore(b) - getScore(a));
614
+ if (alternatives.length === 0)
615
+ return candidate;
616
+ // Build the diversified list: keep dominant up to cap, backfill with alternatives
617
+ const result = [];
618
+ let dominantUsed = 0;
619
+ const altQueue = [...alternatives];
620
+ for (const row of candidate) {
621
+ if (getGraphId(row) === dominantGraphId) {
622
+ if (dominantUsed < dominantCap) {
623
+ result.push(row);
624
+ dominantUsed++;
625
+ }
626
+ else if (altQueue.length > 0) {
627
+ result.push(altQueue.shift());
628
+ }
629
+ else {
630
+ result.push(row); // no alternatives left, keep original
631
+ }
632
+ }
633
+ else {
634
+ result.push(row);
635
+ }
636
+ }
637
+ const promotedCount = dominantCount - dominantCap;
638
+ const actualPromoted = Math.min(promotedCount, alternatives.length);
639
+ if (actualPromoted > 0) {
640
+ console.error(`[diversity] Reranked: ${dominantGraphId} capped from ${dominantCount}→${dominantCap} results, promoted ${actualPromoted} competitive alternative(s) (quality floor: ${qualityThreshold.toFixed(2)})`);
641
+ }
642
+ return result;
643
+ })();
644
+ const finalRows = diversified.slice(0, effectiveLimit);
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
+ }
654
+ data = { rows: finalRows, dataStatus: allRows.length > 0 ? 'ok' : 'NO_MATCH', _routed_graphs: actualSourceGraphs };
655
+ }
656
+ else {
657
+ data = await foddaRequest('POST', `/v1/graphs/${encodeURIComponent(graphId)}/search`, apiKey, resolveUserId(userId, uid), body);
658
+ }
659
+ // ── Track search for frustration detection ──
660
+ const effectiveTrackGraphId = graphId || (data?.rows?.[0]?._use_this_graphId) || 'all';
661
+ const trackResultCount = Array.isArray(data) ? data.length : (data?.rows?.length || 0);
662
+ sessionTracker.trackSearch(query, effectiveTrackGraphId, trackResultCount);
663
+ // Normalize: if API returns a bare array, wrap it into { rows: [...] }
664
+ if (Array.isArray(data)) {
665
+ data = { rows: data, dataStatus: 'ok' };
666
+ }
667
+ // Post-process results
668
+ if (data?.rows) {
669
+ // Build name lookup map once (not per-row)
670
+ const graphNameMap = new Map();
671
+ for (const g of getGraphs())
672
+ graphNameMap.set(g.graph_id, buildDisplayName(g));
673
+ const LEGACY_ALIASES = { 'psfk': 'retail' };
674
+ const enrichNow = Date.now(); // compute once for all rows
675
+ data.rows = data.rows.map((row) => {
676
+ const trimmed = { ...row };
677
+ trimmed._use_this_graphId = row.graphId || graphId;
678
+ if (!trimmed.node_id)
679
+ trimmed.node_id = trimmed.trendId || trimmed.id || trimmed.nodeId || trimmed._id || trimmed.uuid || null;
680
+ if (!trimmed.title)
681
+ trimmed.title = trimmed.trendName || trimmed.display || trimmed.name || null;
682
+ // P0 Item 3: Populate canonical summary from source fields (raw rows have no summary)
683
+ if (!trimmed.summary)
684
+ trimmed.summary = trimmed.description || trimmed.trendDescription || null;
685
+ if (!trimmed.relevance_score)
686
+ trimmed.relevance_score = trimmed.semantic_score || trimmed._score || trimmed.score || null;
687
+ const resolvedId = LEGACY_ALIASES[trimmed._use_this_graphId || ''] || trimmed._use_this_graphId || graphId || '';
688
+ trimmed.graphName = graphNameMap.get(resolvedId) || resolvedId;
689
+ // P0 Item 3: Convert brandNames from pipe-delimited string to capped array
690
+ const rawBrands = typeof trimmed.brandNames === 'string'
691
+ ? trimmed.brandNames.split('|').map((s) => s.trim()).filter(Boolean)
692
+ : Array.isArray(trimmed.brandNames) ? trimmed.brandNames : [];
693
+ trimmed.brandNames = rawBrands.slice(0, 10);
694
+ trimmed.brand_count = rawBrands.length;
695
+ // P0 Item 3: Convert place from comma-delimited string to capped array
696
+ const rawPlaces = typeof trimmed.place === 'string'
697
+ ? trimmed.place.split(',').map((s) => s.trim()).filter(Boolean)
698
+ : Array.isArray(trimmed.place) ? trimmed.place : [];
699
+ trimmed.place = rawPlaces.slice(0, 10);
700
+ trimmed.place_count = rawPlaces.length;
701
+ if (trimmed.whyNow?.length > 200)
702
+ trimmed.whyNow = trimmed.whyNow.substring(0, 200) + '...';
703
+ // P0 Item 4 + Round-2: cap at top-3 BY RELEVANCE (API order), not recency
704
+ if (trimmed.evidence?.length > 0) {
705
+ trimmed.evidence_count = trimmed.evidence.length; // total before cap
706
+ trimmed.evidence = enrichEvidence(trimmed.evidence.slice(0, 3));
707
+ }
708
+ else {
709
+ trimmed.evidence_count = trimmed.evidence_count || trimmed.evidenceCount || 0;
710
+ if ((include_evidence ?? true) && trimmed.evidence_count > 0) {
711
+ trimmed.evidence_status = 'Evidence expected but not returned by API';
712
+ }
713
+ }
714
+ const drillTrendName = trimmed.title || trimmed.trendName || 'this trend';
715
+ const drillGraphId = trimmed._use_this_graphId || graphId || '';
716
+ trimmed.suggested_drill_down = `Tell me more about "${drillTrendName}" from the ${trimmed.graphName || drillGraphId} graph. What is driving this and what are the key signals?`;
717
+ trimmed.trendLifecycle = computeLifecycle(trimmed, enrichNow);
718
+ trimmed.momentum = computeMomentum(trimmed, enrichNow);
719
+ trimmed.fastMover = isFastMover(trimmed, enrichNow);
720
+ trimmed.graphBadge = GRAPH_BADGES[trimmed._use_this_graphId || graphId || ''] || '○';
721
+ return trimmed;
722
+ });
723
+ // P0 Item 6: Filter out deprecated graph rows
724
+ data.rows = data.rows.filter((r) => {
725
+ const gid = r._use_this_graphId || r.graphId || '';
726
+ return !DEPRECATED_GRAPH_IDS.has(gid);
727
+ });
728
+ }
729
+ if (data?.rows) {
730
+ data.results = data.rows;
731
+ if (data.total === undefined)
732
+ data.total = data.rows.length;
733
+ }
734
+ // When graphId was omitted (all-graph search), use first result's graph or 'retail'
735
+ const effectiveGraphId = graphId || (data?.rows?.[0]?._use_this_graphId) || (data?.rows?.[0]?.graphId) || 'retail';
736
+ data.theme = getFoddaTheme(effectiveGraphId);
737
+ const primaryCatalogEntry = getGraphs().find(g => g.graph_id === effectiveGraphId);
738
+ const primaryGraphName = primaryCatalogEntry ? buildDisplayName(primaryCatalogEntry) : effectiveGraphId;
739
+ // P0 Item 6: Attribution covers all source graphs, resolved via catalog
740
+ const attrGraphIds = [...new Set((data.rows || []).map((r) => r._use_this_graphId || r.graphId).filter(Boolean))];
741
+ if (attrGraphIds.length > 1) {
742
+ const attrNames = attrGraphIds.map(id => {
743
+ const resolved = id === 'psfk' ? 'retail' : id;
744
+ const entry = getGraphs().find((g) => g.graph_id === resolved);
745
+ return entry ? buildDisplayName(entry) : resolved;
746
+ });
747
+ data._attribution = `Data sourced from ${attrNames.join(', ')}`;
748
+ }
749
+ else {
750
+ data._attribution = `Data sourced from ${primaryGraphName}`;
751
+ }
752
+ // Suggested next prompts
753
+ const rows = data.rows || [];
754
+ const prompts = [];
755
+ const topTrend = rows[0];
756
+ if (topTrend) {
757
+ const trendName = topTrend.trendName || topTrend.display || topTrend.name || 'this trend';
758
+ const nodeGraphId = topTrend._use_this_graphId || effectiveGraphId;
759
+ prompts.push({ label: 'Deep dive', prompt: `Show me the sources and evidence behind "${trendName}" from the ${nodeGraphId} graph`, type: 'deeper_dive' });
760
+ const econMap = { retail: 'Census retail sales and BEA consumer spending', fashion: 'BEA spending on clothing and BLS apparel CPI', beauty: 'FDA safety data and PubMed research trends', sports: 'BEA recreation spending and Wikipedia cultural attention', sic: 'FRED consumer sentiment and Pew survey data' };
761
+ prompts.push({ label: 'Economic context', prompt: `Pull supplemental data from ${econMap[effectiveGraphId] || 'FRED economic indicators and OECD data'} to validate these trends`, type: 'economic_lens' });
762
+ if (effectiveGraphId !== 'sic' && rows.length >= 2)
763
+ prompts.push({ label: 'Cultural lens', prompt: `How does "${trendName}" show up in the SIC cultural intelligence graph?`, type: 'adjacent_angle' });
764
+ const hasBrands = rows.some((r) => r.evidence?.some((e) => e.brandNames?.length > 0));
765
+ if (hasBrands && prompts.length < 4)
766
+ prompts.push({ label: 'Brand landscape', prompt: `Which brands are driving "${trendName}"? Show me the competitive landscape.`, type: 'brand_focus' });
767
+ }
768
+ data.suggested_next_prompts = prompts.slice(0, 3);
769
+ // Pre-formatted branded follow-up block for the LLM to render verbatim
770
+ if (prompts.length > 0) {
771
+ data._fodda_followup = `**Fodda →** ${prompts.map(p => p.label).join(' · ')}`;
772
+ }
773
+ // Inject _render_instructions for LLM clients that don't read server-level instructions
774
+ const resultGraphIds = [...new Set((data.rows || []).map((r) => r._use_this_graphId || r.graphId).filter(Boolean))];
775
+ data._render_instructions = buildRenderInstructions({
776
+ hasWidget: true, // will be determined later, but default to true
777
+ hasPrompts: prompts.length > 0,
778
+ hasEvidence: (data.rows || []).some((r) => r.evidence?.length > 0),
779
+ graphWebpageUrls: collectGraphWebpageUrls(resultGraphIds),
780
+ });
781
+ // Phase 2 envelope enrichment
782
+ const enrichedRows = data.rows || [];
783
+ const mainstream = enrichedRows.filter((r) => (r.evidence_count || r.evidenceCount || 0) >= 3);
784
+ const weakSignals = enrichedRows.filter((r) => (r.evidence_count || r.evidenceCount || 0) < 3 && (r.trendLifecycle === 'emerging' || r.trendLifecycle === 'unknown'));
785
+ if (weakSignals.length > 0) {
786
+ data.mainstream = mainstream;
787
+ data.weak_signals = weakSignals;
788
+ }
789
+ const allFirstSeen = enrichedRows.map((r) => r.firstSeen).filter(Boolean).sort();
790
+ const allLastSeen = enrichedRows.map((r) => r.lastSeen).filter(Boolean).sort().reverse();
791
+ if (allFirstSeen.length > 0 && allLastSeen.length > 0) {
792
+ data.queryTimeline = { earliest: allFirstSeen[0], latest: allLastSeen[0], span: `${new Date(allFirstSeen[0]).getFullYear()}–${new Date(allLastSeen[0]).getFullYear()}` };
793
+ }
794
+ const places = enrichedRows.map((r) => r.place || r.geographical_region).filter(Boolean);
795
+ if (places.length >= 3) {
796
+ const uniqueRegions = new Set(places.flatMap((p) => Array.isArray(p) ? p : p.split(',').map((s) => s.trim())));
797
+ if (uniqueRegions.size === 1)
798
+ data.geoBias = { concentrated: true, region: [...uniqueRegions][0], note: 'Results are geographically concentrated' };
799
+ }
800
+ if (enrichedRows.length < 3 || enrichedRows.every((r) => (r.evidence_count || r.evidenceCount || 0) < 3)) {
801
+ data.research_gaps = { thin_coverage: true, note: 'The graph has limited coverage on this topic. These are the closest matches.' };
802
+ }
803
+ // Confidence-gated fallback: auto-broaden thin results
804
+ if (enrichedRows.length < 3 && query.split(' ').length > 3 && effectiveGraphId) {
805
+ try {
806
+ const shorterQuery = query.split(' ').slice(0, 3).join(' ');
807
+ const fallback = await foddaRequest('POST', `/v1/graphs/${encodeURIComponent(effectiveGraphId)}/search`, apiKey, resolveUserId(userId, uid), { query: shorterQuery, limit: 10, use_semantic: true, include_evidence: false });
808
+ const fallbackRows = Array.isArray(fallback) ? fallback : fallback?.rows || [];
809
+ if (fallbackRows.length > 0) {
810
+ const existingIds = new Set(enrichedRows.map((r) => r.node_id || r.trendId));
811
+ const newRows = fallbackRows.filter((r) => !existingIds.has(r.node_id || r.trendId));
812
+ if (newRows.length > 0) {
813
+ const enrichedNew = newRows.map((row) => {
814
+ const t = { ...row };
815
+ t._use_this_graphId = row.graphId || effectiveGraphId;
816
+ t.trendLifecycle = computeLifecycle(t);
817
+ t.momentum = computeMomentum(t);
818
+ t.fastMover = isFastMover(t);
819
+ t._broadened = true;
820
+ return t;
821
+ });
822
+ data.rows.push(...enrichedNew);
823
+ data.results = data.rows;
824
+ data.total = data.rows.length;
825
+ data._broadened = true;
826
+ }
827
+ }
828
+ }
829
+ catch { /* Broadening failed silently */ }
830
+ }
831
+ // ── Low-credit warning for all users — utilizes dynamic Stripe links from API ──
832
+ appendUsageWarning(data, resolveUserId(userId));
833
+ // ── Supplemental data — macro context for all queries with results ──
834
+ let supplemental = { google_trends: null, census_retail: null };
835
+ const resultCount = (data?.rows || []).length;
836
+ if (resultCount >= 1) { // Was ≥3 — thin queries need macro context most
837
+ const [googleTrendsResult, censusResult] = await Promise.allSettled([
838
+ foddaRequest('GET', `/v1/supplemental/google-trends?query=${encodeURIComponent(query)}&geo=US&timeframe=today+12-m`, apiKey, resolveUserId(userId, uid)),
839
+ foddaRequest('GET', `/v1/supplemental/census/retail-snapshot`, apiKey, resolveUserId(userId, uid)),
840
+ ]);
841
+ supplemental = {
842
+ google_trends: googleTrendsResult.status === 'fulfilled' ? googleTrendsResult.value : null,
843
+ census_retail: censusResult.status === 'fulfilled' ? censusResult.value : null,
844
+ };
845
+ }
846
+ else {
847
+ console.error(`[search_graph] Skipping supplemental fetch — only ${resultCount} results (threshold: 3)`);
848
+ }
849
+ // ── Skill post-processing — call enabled output skills via Core API ──
850
+ let skillResults = [];
851
+ if (!skip_skills && sessionSkills.length > 0 && resultCount >= 1) {
852
+ const skillInput = buildSkillInput(query, data, effectiveGraphId, primaryGraphName, supplemental);
853
+ skillResults = await callOutputSkills(sessionSkills, skillInput, apiKey, resolveUserId(userId, uid));
854
+ const applied = skillResults.filter(r => r.success);
855
+ if (applied.length > 0) {
856
+ data._skills_applied = applied.map(r => ({ id: r.skillId, name: r.skillName, durationMs: r.durationMs }));
857
+ console.error(`[search_graph] ${applied.length} skill(s) applied: ${applied.map(r => r.skillName).join(', ')}`);
858
+ }
859
+ const failed = skillResults.filter(r => !r.success);
860
+ if (failed.length > 0) {
861
+ console.error(`[search_graph] ${failed.length} skill(s) failed: ${failed.map(r => `${r.skillName}: ${r.error}`).join(', ')}`);
862
+ }
863
+ }
864
+ else if (skip_skills && sessionSkills.length > 0) {
865
+ console.error(`[search_graph] Skills skipped (user requested skip_skills)`);
866
+ data._skills_skipped = sessionSkills.map(s => s.name);
867
+ }
868
+ // ── Server-side widget rendering ──
869
+ const searchWidget = await renderSearchWidget(data, query, primaryGraphName, supplemental);
870
+ if (searchWidget.widget_html) {
871
+ // Also cache for direct browser access via /widget/:id
872
+ storeWidget(searchWidget.widget_html);
873
+ // P0 Item 3+4: Dedupe aliased fields; keep capped evidence inline
874
+ const liteData = { ...data };
875
+ delete liteData.results; // exact copy of rows
876
+ delete liteData.weak_signals; // subset of rows
877
+ liteData.rows = (data.rows || []).map((r) => {
878
+ const out = { ...r };
879
+ // ID aliases → keep node_id
880
+ delete out.trendId;
881
+ delete out.nodeId;
882
+ delete out.uuid;
883
+ if (out.node_id) {
884
+ delete out.id;
885
+ delete out._id;
886
+ }
887
+ // Score aliases → keep relevance_score
888
+ if (!out.relevance_score)
889
+ out.relevance_score = out.semantic_score || out._score || out.score || null;
890
+ delete out._score;
891
+ delete out.semantic_score;
892
+ delete out.score;
893
+ // Description aliases → keep summary
894
+ delete out.description;
895
+ delete out.trendDescription;
896
+ // Drop adjacentPossibilities and evidenceCount (canonical is evidence_count)
897
+ delete out.adjacentPossibilities;
898
+ delete out.evidenceCount;
899
+ return out;
900
+ });
901
+ // Size check: if total payload exceeds ~30KB, skip widget to avoid context overflow
902
+ const jsonPayload = JSON.stringify(liteData, null, 2);
903
+ const totalSize = searchWidget.widget_html.length + jsonPayload.length;
904
+ if (totalSize > 30000) {
905
+ console.error(`[search_graph] Payload too large (${(totalSize / 1024).toFixed(1)}KB) — skipping widget HTML, sending liteData JSON + design brief`);
906
+ // ── Query-level billing (large payload path) ──
907
+ chargeQuery({ queryTypeCode: 'topic_research', apiKey, userId: resolveUserId(userId, uid), query, foddaRequest, spt: sptCtx?.token })
908
+ .catch(e => console.error('[search_graph] chargeQuery failed:', e.message));
909
+ return { content: [
910
+ { type: 'text', text: jsonPayload },
911
+ { type: 'text', text: FODDA_WIDGET_DESIGN_BRIEF },
912
+ ] };
913
+ }
914
+ const widgetResponse = { content: [
915
+ { type: 'text', text: '── RAW DATA (for follow-up reasoning) ──\n' + jsonPayload },
916
+ { type: 'text', text: searchWidget.editorial_instruction },
917
+ { 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' + searchWidget.widget_html },
918
+ // Append skill outputs as additional content blocks
919
+ ...skillResults
920
+ .filter(r => r.success && r.output)
921
+ .map(r => ({
922
+ type: 'text',
923
+ text: `── SKILL: ${r.skillName} ──\n${r.output}\n── END SKILL: ${r.skillName} ──`,
924
+ })),
925
+ ] };
926
+ // ── Query-level billing (rich widget path) ──
927
+ chargeQuery({ queryTypeCode: 'topic_research', apiKey, userId: resolveUserId(userId, uid), query, foddaRequest, spt: sptCtx?.token })
928
+ .catch(e => console.error('[search_graph] chargeQuery failed:', e.message));
929
+ return widgetResponse;
930
+ }
931
+ // Fallback: <3 results or no widget — give Claude the shell + stripped data
932
+ const shellSources = [...new Set((data?.rows || []).map((r) => r.graphName).filter(Boolean))];
933
+ const shellHtml = getShellTemplate(`Search: ${query}`, shellSources.length ? shellSources : [primaryGraphName]);
934
+ // P0 Item 3+4: Dedupe aliased fields in fallback path; keep capped evidence inline
935
+ const fallbackData = { ...data };
936
+ delete fallbackData.results;
937
+ delete fallbackData.weak_signals;
938
+ fallbackData.rows = (data.rows || []).map((r) => {
939
+ const out = { ...r };
940
+ delete out.trendId;
941
+ delete out.nodeId;
942
+ delete out.uuid;
943
+ if (out.node_id) {
944
+ delete out.id;
945
+ delete out._id;
946
+ }
947
+ if (!out.relevance_score)
948
+ out.relevance_score = out.semantic_score || out._score || out.score || null;
949
+ delete out._score;
950
+ delete out.semantic_score;
951
+ delete out.score;
952
+ delete out.description;
953
+ delete out.trendDescription;
954
+ delete out.adjacentPossibilities;
955
+ delete out.evidenceCount;
956
+ return out;
957
+ });
958
+ // ── Query-level billing (fallback path) ──
959
+ chargeQuery({ queryTypeCode: 'topic_research', apiKey, userId: resolveUserId(userId, uid), query, foddaRequest, spt: sptCtx?.token })
960
+ .catch(e => console.error('[search_graph] chargeQuery failed:', e.message));
961
+ return { content: [
962
+ { type: 'text', text: JSON.stringify(fallbackData, null, 2) },
963
+ { type: 'text', text: '── FODDA SHELL TEMPLATE ──\nUse this shell to wrap your widget response. Replace {{CONTENT}} with your HTML and {{EXTRA_CSS}} with any additional styles.\n\n' + shellHtml },
964
+ { type: 'text', text: FODDA_COMPONENT_GUIDE },
965
+ // Append skill outputs (if any ran despite thin results)
966
+ ...skillResults
967
+ .filter(r => r.success && r.output)
968
+ .map(r => ({
969
+ type: 'text',
970
+ text: `── SKILL: ${r.skillName} ──\n${r.output}\n── END SKILL: ${r.skillName} ──`,
971
+ })),
972
+ // ── Frustration hint (if detected) ──
973
+ ...((() => {
974
+ const hint = sessionTracker.detectFrustration();
975
+ if (hint) {
976
+ const frustrationUser = userId !== 'anonymous' ? userId : (apiKey ? `key:${apiKey.substring(0, 12)}…` : 'anonymous');
977
+ sessionTracker.postFrustrationToSlack(frustrationUser);
978
+ }
979
+ return hint ? [{ type: 'text', text: `\n---\n${hint}\n---` }] : [];
980
+ })()),
981
+ ] };
982
+ }
983
+ catch (err) {
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.)
987
+ const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
988
+ if (trialResult)
989
+ return trialResult;
990
+ return await handleAccessError(err, 'search_graph');
991
+ }
992
+ });
993
+ // --- get_neighbors ---
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.', {
995
+ graphId: z.string().describe(GRAPH_ID_DESC),
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.'),
997
+ userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
998
+ relationship_types: z.array(z.string()).optional().describe("Filter by relationship types: 'EVIDENCED_BY', 'RELATED_TO', 'SEMANTICALLY_SIMILAR', 'ASSOCIATED_BRAND', 'MENTIONS_BRAND', 'IN_LOCATION'"),
999
+ direction: z.enum(['in', 'out']).optional().describe("Traversal direction: 'out' (default) follows outgoing edges, 'in' follows incoming edges"),
1000
+ depth: z.number().optional().describe('Traversal depth (default 1, max 2)'),
1001
+ limit: z.number().optional().describe('Maximum results (default 50)')
1002
+ }, { title: 'Explore Graph Neighbors', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ graphId, seed_node_ids, userId: uid, relationship_types, direction, depth, limit }) => {
1003
+ try {
1004
+ const body = {
1005
+ seed_node_ids,
1006
+ depth: Math.min(depth || 1, 2),
1007
+ limit: Math.min(limit || 50, 50),
1008
+ };
1009
+ if (relationship_types)
1010
+ body.relationship_types = relationship_types;
1011
+ if (direction)
1012
+ body.direction = direction;
1013
+ let data = await foddaRequest('POST', `/v1/graphs/${encodeURIComponent(graphId)}/neighbors`, apiKey, resolveUserId(userId, uid), body);
1014
+ appendUsageWarning(data, resolveUserId(userId));
1015
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1016
+ }
1017
+ catch (err) {
1018
+ const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
1019
+ if (trialResult)
1020
+ return trialResult;
1021
+ const msg = err.response?.data?.error?.message || err.response?.data?.message || err.message;
1022
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
1023
+ }
1024
+ });
1025
+ // --- get_evidence ---
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.', {
1027
+ graphId: z.string().describe(GRAPH_ID_DESC),
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."),
1029
+ userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
1030
+ top_k: z.number().optional().describe('Number of evidence items to return (default 5)')
1031
+ }, { title: 'Get Supporting Evidence', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ graphId, for_node_id, userId: uid, top_k }) => {
1032
+ try {
1033
+ if (graphId === 'psfk')
1034
+ graphId = 'retail';
1035
+ const body = { for_node_id, top_k: Math.min(top_k || 5, 10) };
1036
+ let data;
1037
+ data = await foddaRequest('POST', `/v1/graphs/${encodeURIComponent(graphId)}/evidence`, apiKey, resolveUserId(userId, uid), body);
1038
+ // Enrich evidence with pre-formatted citations
1039
+ if (data?.evidence)
1040
+ data.evidence = enrichEvidence(data.evidence);
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;
1045
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1046
+ }
1047
+ catch (err) {
1048
+ const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
1049
+ if (trialResult)
1050
+ return trialResult;
1051
+ const msg = err.response?.data?.error?.message || err.response?.data?.message || err.message;
1052
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
1053
+ }
1054
+ });
1055
+ // --- get_node ---
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.', {
1057
+ graphId: z.string().describe(GRAPH_ID_DESC),
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."),
1059
+ userId: z.string().optional().describe('Optional user identifier for trial usage tracking.')
1060
+ }, { title: 'Get Node Details', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ graphId, nodeId, userId: uid }) => {
1061
+ try {
1062
+ if (graphId === 'psfk')
1063
+ graphId = 'retail';
1064
+ const data = await foddaRequest('GET', `/v1/graphs/${encodeURIComponent(graphId)}/nodes/${nodeId}`, apiKey, resolveUserId(userId, uid));
1065
+ // Inject theme block for visualization branding
1066
+ if (data && typeof data === 'object') {
1067
+ data.theme = getFoddaTheme(graphId);
1068
+ }
1069
+ appendUsageWarning(data, resolveUserId(userId));
1070
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1071
+ }
1072
+ catch (err) {
1073
+ const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
1074
+ if (trialResult)
1075
+ return trialResult;
1076
+ const msg = err.response?.data?.error?.message || err.response?.data?.message || err.message;
1077
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
1078
+ }
1079
+ });
1080
+ // --- get_label_values ---
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.', {
1082
+ graphId: z.string().describe(GRAPH_ID_DESC),
1083
+ label: z.string().describe("The label to fetch values for (e.g., 'Brand', 'Location', 'Technology', 'Audience', 'RetailerType', 'Trend')"),
1084
+ userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
1085
+ property: z.string().optional().describe('Optional property to return values for. Defaults vary by label.')
1086
+ }, { title: 'Get Category Values', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ graphId, label, userId: uid, property }) => {
1087
+ try {
1088
+ if (graphId === 'psfk')
1089
+ graphId = 'retail';
1090
+ const propParam = property ? `?property=${encodeURIComponent(property)}` : '';
1091
+ const data = await foddaRequest('GET', `/v1/graphs/${encodeURIComponent(graphId)}/labels/${label}/values${propParam}`, apiKey, resolveUserId(userId, uid));
1092
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1093
+ }
1094
+ catch (err) {
1095
+ const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
1096
+ if (trialResult)
1097
+ return trialResult;
1098
+ const msg = err.response?.data?.error?.message || err.response?.data?.message || err.message;
1099
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
1100
+ }
1101
+ });
1102
+ // --- discover_adjacent_trends ---
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.', {
1104
+ graphId: z.string().describe(GRAPH_ID_DESC),
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."),
1106
+ userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
1107
+ min_score: z.number().optional().describe('Minimum similarity score threshold (0-1). Default: 0.80'),
1108
+ limit: z.number().optional().describe('Maximum number of adjacent trends to return. Default: 10'),
1109
+ include_editorial: z.boolean().optional().describe('If true, also include editorially linked trends. Default: false')
1110
+ }, { title: 'Discover Adjacent Trends', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ graphId, trend_id, userId: uid, min_score, limit, include_editorial }) => {
1111
+ try {
1112
+ if (graphId === 'psfk')
1113
+ graphId = 'retail';
1114
+ // Log query to Questions table (fire-and-forget, before cache)
1115
+ logUserQuery(trend_id, 'discover_adjacent_trends', graphId);
1116
+ const params = new URLSearchParams({ node_id: trend_id });
1117
+ if (min_score !== undefined)
1118
+ params.set('min_score', String(min_score));
1119
+ params.set('limit', String(Math.min(limit || 10, 20)));
1120
+ if (include_editorial !== undefined)
1121
+ params.set('include_editorial', String(include_editorial));
1122
+ let data = await foddaRequest('GET', `/v1/graphs/${encodeURIComponent(graphId)}/adjacent?${params.toString()}`, apiKey, resolveUserId(userId, uid));
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;
1127
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1128
+ }
1129
+ catch (err) {
1130
+ const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
1131
+ if (trialResult)
1132
+ return trialResult;
1133
+ const msg = err.response?.data?.error?.message || err.response?.data?.message || err.message;
1134
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
1135
+ }
1136
+ });
1137
+ // --- brand_tracker ---
1138
+ const executeBrandTracker = async (brand_name, uid, graph_ids, include_evidence, max_evidence) => {
1139
+ const brandName = brand_name.trim();
1140
+ const includeEvidence = include_evidence !== false;
1141
+ const maxEv = Math.min(max_evidence || 10, 25);
1142
+ const brandLower = brandName.toLowerCase();
1143
+ // ── Fire static/brand-only supplemental queries in parallel ──
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; });
1151
+ // Build graph lookup map
1152
+ const graphLookup = new Map();
1153
+ for (const g of getGraphs())
1154
+ graphLookup.set(g.graph_id, g);
1155
+ // ── Data collection arrays ──
1156
+ let allTrends = [];
1157
+ let allEvidence = [];
1158
+ const graphPresence = {};
1159
+ const competitorCounts = {};
1160
+ const competitorGraphs = {}; // track which graphs each competitor appears in
1161
+ let usedCypherEndpoint = false;
1162
+ // ── Strategy 1: Single Cypher endpoint (fast path) ──
1163
+ try {
1164
+ const params = new URLSearchParams();
1165
+ params.set('maxEvidence', String(maxEv));
1166
+ params.set('limit', '50');
1167
+ const cypherData = await foddaRequest('POST', `/v1/brand-intelligence/${encodeURIComponent(brandName)}?${params.toString()}`, apiKey, resolveUserId(userId, uid));
1168
+ if (cypherData?.ok && cypherData.trend_footprint) {
1169
+ usedCypherEndpoint = true;
1170
+ // Map Cypher response → MCP profile shape
1171
+ for (const t of cypherData.trend_footprint) {
1172
+ const catalogEntry = graphLookup.get(t.graphId);
1173
+ const graphName = catalogEntry ? buildDisplayName(catalogEntry) : t.graphId;
1174
+ allTrends.push({
1175
+ trend_name: (t.trendName || '').replace(/^\[(?:REVIW|REVIEW|DRAFT|WIP)\]\s*/i, '').trim(),
1176
+ trend_description: t.trendDescription || '',
1177
+ graphId: t.graphId,
1178
+ graphName,
1179
+ signal_score: t.signalScore || null,
1180
+ lifecycle: computeLifecycle({ firstSeen: t.firstSeen, lastSeen: t.lastSeen, evidenceCount: t.evidenceCount, signal_score: t.signalScore }),
1181
+ momentum: computeMomentum({ firstSeen: t.firstSeen, lastSeen: t.lastSeen, evidenceCount: t.evidenceCount }),
1182
+ evidence_count: t.evidenceCount || 0,
1183
+ node_id: t.nodeId || t.trendId,
1184
+ _use_this_graphId: t.graphId,
1185
+ freshnessDays: t.freshnessDays ?? (() => {
1186
+ // Fallback: compute from catalog graph published_date or last_updated
1187
+ const dateStr = catalogEntry?.published_date || catalogEntry?.last_updated;
1188
+ if (dateStr) {
1189
+ const d = new Date(dateStr);
1190
+ if (!isNaN(d.getTime()))
1191
+ return Math.floor((Date.now() - d.getTime()) / 86400000);
1192
+ }
1193
+ return null;
1194
+ })(),
1195
+ });
1196
+ // Recompute lifecycle with freshnessDays now available
1197
+ const lastTrend = allTrends[allTrends.length - 1];
1198
+ lastTrend.lifecycle = computeLifecycle({ ...lastTrend, firstSeen: t.firstSeen, lastSeen: t.lastSeen, evidenceCount: t.evidenceCount, signal_score: t.signalScore, freshnessDays: lastTrend.freshnessDays });
1199
+ // Collect evidence — filter to items that actually mention the brand
1200
+ if (includeEvidence && t.evidence) {
1201
+ for (const ev of t.evidence) {
1202
+ const evBrands = typeof ev.brandNames === 'string' ? ev.brandNames.split('|').map((s) => s.trim()).filter(Boolean) : (Array.isArray(ev.brandNames) ? ev.brandNames : []);
1203
+ const evText = `${ev.title || ''} ${ev.summary || ''}`.toLowerCase();
1204
+ const evMentionsBrand = evBrands.some((b) => b.toLowerCase().includes(brandLower)) || evText.includes(brandLower);
1205
+ if (!evMentionsBrand)
1206
+ continue;
1207
+ allEvidence.push({
1208
+ title: ev.title,
1209
+ excerpt: ev.summary || '',
1210
+ source_url: ev.sourceUrl,
1211
+ image_url: ev.imageUrl || null,
1212
+ published_at: ev.publishedAt || null,
1213
+ category: ev.category || 'Case Study',
1214
+ place: ev.place || null,
1215
+ graphId: t.graphId,
1216
+ graphName,
1217
+ brands_mentioned: evBrands,
1218
+ linked_trend: t.trendName,
1219
+ formatted_citation: ev.formatted_citation || (ev.title && ev.sourceUrl ? `[${ev.title}](${ev.sourceUrl})` : ev.title || ''),
1220
+ speaker_name: ev.speakerName || null,
1221
+ speaker_title: ev.speakerTitle || null,
1222
+ });
1223
+ // Track which graphs each co-occurring brand appears in (for sector-aware competitor labels)
1224
+ // Skip earnings/finance graphs — analyst transcripts co-mention unrelated brands
1225
+ if (!t.graphId.includes('earnings') && !t.graphId.includes('finance')) {
1226
+ for (const b of evBrands) {
1227
+ const bLower = (b || '').toLowerCase();
1228
+ if (bLower && bLower !== brandLower && !bLower.includes(brandLower) && !brandLower.includes(bLower)) {
1229
+ if (!competitorGraphs[b])
1230
+ competitorGraphs[b] = new Set();
1231
+ competitorGraphs[b].add(t.graphId);
1232
+ }
1233
+ }
1234
+ }
1235
+ }
1236
+ }
1237
+ }
1238
+ // Map cross-graph presence (must happen BEFORE competitor mapping so graphPresence is populated)
1239
+ for (const g of (cypherData.cross_graph_presence || [])) {
1240
+ const catalogEntry = graphLookup.get(g.graphId);
1241
+ const graphName = catalogEntry ? buildDisplayName(catalogEntry) : g.graphId;
1242
+ graphPresence[g.graphId] = {
1243
+ graphId: g.graphId,
1244
+ graphName,
1245
+ graphType: catalogEntry?.graph_type || 'expert',
1246
+ trendCount: g.trendCount || 0,
1247
+ evidenceCount: g.evidenceCount || 0,
1248
+ };
1249
+ }
1250
+ // Map co-occurring brands — use graphIds from API response + evidence extraction
1251
+ for (const c of (cypherData.competitive_context?.co_occurring || [])) {
1252
+ competitorCounts[c.brand] = c.co_occurrences || c.coOccurrences || 1;
1253
+ // API now returns graphIds per competitor — merge with any evidence-derived ones
1254
+ if (c.graphIds && Array.isArray(c.graphIds)) {
1255
+ if (!competitorGraphs[c.brand])
1256
+ competitorGraphs[c.brand] = new Set();
1257
+ for (const gId of c.graphIds)
1258
+ competitorGraphs[c.brand].add(gId);
1259
+ }
1260
+ }
1261
+ // Filter stale trends — drop trends older than 365 days to focus on current footprint
1262
+ const STALE_THRESHOLD_DAYS = 365;
1263
+ const freshTrends = allTrends.filter((t) => {
1264
+ if (t.freshnessDays === null || t.freshnessDays === undefined)
1265
+ return true; // keep if no data
1266
+ return t.freshnessDays <= STALE_THRESHOLD_DAYS;
1267
+ });
1268
+ if (freshTrends.length > 0 && freshTrends.length < allTrends.length) {
1269
+ console.error(`[brand_tracker] Filtered ${allTrends.length - freshTrends.length} stale trend(s) (>${STALE_THRESHOLD_DAYS} days old)`);
1270
+ allTrends.length = 0;
1271
+ allTrends.push(...freshTrends);
1272
+ }
1273
+ // ── Evidence backfill: Cypher may return thin or no evidence arrays ──
1274
+ const MIN_EVIDENCE_THRESHOLD = 5;
1275
+ const trendsWithMissingEvidence = includeEvidence && allEvidence.length < MIN_EVIDENCE_THRESHOLD
1276
+ ? allTrends.filter(t => (t.evidence_count || 0) > 0)
1277
+ : [];
1278
+ if (trendsWithMissingEvidence.length > 0) {
1279
+ console.error(`[brand_tracker] Cypher returned ${trendsWithMissingEvidence.length} trend(s) with evidenceCount but no evidence array — backfilling via /evidence`);
1280
+ const evBackfillResults = await Promise.allSettled(trendsWithMissingEvidence.slice(0, 5).map(async (t) => {
1281
+ try {
1282
+ const evData = await foddaRequest('POST', `/v1/graphs/${encodeURIComponent(t.graphId)}/evidence`, apiKey, resolveUserId(userId, uid), { for_node_id: t.node_id, top_k: maxEv });
1283
+ return { trend: t, evidence: evData?.evidence || [] };
1284
+ }
1285
+ catch {
1286
+ return { trend: t, evidence: [] };
1287
+ }
1288
+ }));
1289
+ for (const r of evBackfillResults) {
1290
+ if (r.status !== 'fulfilled')
1291
+ continue;
1292
+ const { trend, evidence: evItems } = r.value;
1293
+ for (const ev of evItems) {
1294
+ // Filter to evidence that actually mentions the brand
1295
+ const evBrandsRaw = typeof ev.brandNames === 'string' ? ev.brandNames.split('|').map((s) => s.trim()).filter(Boolean) : (Array.isArray(ev.brandNames) ? ev.brandNames : []);
1296
+ const evText = `${ev.title || ''} ${ev.snippet || ev.summary || ''}`.toLowerCase();
1297
+ const evMentionsBrand = evBrandsRaw.some((b) => b.toLowerCase().includes(brandLower)) || evText.includes(brandLower);
1298
+ if (!evMentionsBrand)
1299
+ continue;
1300
+ allEvidence.push({
1301
+ title: ev.title,
1302
+ excerpt: ev.snippet || ev.summary || '',
1303
+ source_url: ev.sourceUrl,
1304
+ image_url: ev.imageUrl || null,
1305
+ published_at: ev.publishedAt || null,
1306
+ category: ev.category || 'Case Study',
1307
+ place: ev.place || null,
1308
+ graphId: trend.graphId,
1309
+ graphName: trend.graphName,
1310
+ brands_mentioned: evBrandsRaw,
1311
+ linked_trend: trend.trend_name,
1312
+ formatted_citation: ev.formatted_citation || (ev.title && ev.sourceUrl ? `[${ev.title}](${ev.sourceUrl})` : ev.title || ''),
1313
+ speaker_name: ev.speakerName || null,
1314
+ speaker_title: ev.speakerTitle || null,
1315
+ });
1316
+ // Collect competitor brands from backfilled evidence
1317
+ for (const b of evBrandsRaw) {
1318
+ const bLower = b.toLowerCase();
1319
+ if (bLower !== brandLower && !bLower.includes(brandLower) && !brandLower.includes(bLower)) {
1320
+ competitorCounts[b] = (competitorCounts[b] || 0) + 1;
1321
+ if (!competitorGraphs[b])
1322
+ competitorGraphs[b] = new Set();
1323
+ competitorGraphs[b].add(trend.graphId);
1324
+ }
1325
+ }
1326
+ }
1327
+ }
1328
+ if (allEvidence.length > 0) {
1329
+ console.error(`[brand_tracker] Evidence backfill recovered ${allEvidence.length} evidence item(s)`);
1330
+ }
1331
+ }
1332
+ }
1333
+ }
1334
+ catch (cypherErr) {
1335
+ console.error(`[brand_tracker] Cypher endpoint failed (${cypherErr.message}) — falling back to multi-search`);
1336
+ }
1337
+ // ── Strategy 2: Fallback/supplement — parallel per-graph search ──
1338
+ const needsSupplement = !usedCypherEndpoint || allTrends.length < 3;
1339
+ if (needsSupplement) {
1340
+ const cypherGraphIds = new Set(Object.keys(graphPresence));
1341
+ const NO_BRAND_GRAPHS = new Set([
1342
+ 'braze-2026-trends',
1343
+ 'ezra-eeman-wayfinder',
1344
+ 'havas-media-trends',
1345
+ 'publicis-sapient-next-graph',
1346
+ ]);
1347
+ const allGraphs = getLiveGraphs();
1348
+ const MAX_BRAND_FALLBACK_GRAPHS = 8; // Cap fan-out to prevent flooding API
1349
+ const graphsToSearch = graph_ids?.length
1350
+ ? allGraphs.filter(g => graph_ids.includes(g.graph_id))
1351
+ : allGraphs.filter(g => (g.graph_type === 'domain' || g.graph_type === 'expert') && !NO_BRAND_GRAPHS.has(g.graph_id) && !cypherGraphIds.has(g.graph_id)).slice(0, MAX_BRAND_FALLBACK_GRAPHS);
1352
+ if (graphsToSearch.length > 0) {
1353
+ const searchResults = await Promise.allSettled(graphsToSearch.map(async (graph) => {
1354
+ try {
1355
+ const body = {
1356
+ query: brandName,
1357
+ limit: 10,
1358
+ use_semantic: true,
1359
+ include_evidence: includeEvidence,
1360
+ };
1361
+ const searchPromise = foddaRequest('POST', `/v1/graphs/${encodeURIComponent(graph.graph_id)}/search`, apiKey, resolveUserId(userId, uid), body);
1362
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 8000));
1363
+ const data = await Promise.race([searchPromise, timeoutPromise]);
1364
+ const rows = Array.isArray(data) ? data : data?.rows || [];
1365
+ return { graphId: graph.graph_id, graph, rows };
1366
+ }
1367
+ catch {
1368
+ return { graphId: graph.graph_id, graph, rows: [] };
1369
+ }
1370
+ }));
1371
+ for (const result of searchResults) {
1372
+ if (result.status !== 'fulfilled')
1373
+ continue;
1374
+ const { graphId, graph, rows } = result.value;
1375
+ if (rows.length === 0)
1376
+ continue;
1377
+ const graphName = buildDisplayName(graph);
1378
+ const brandRows = rows.filter((row) => {
1379
+ const nameMatch = (row.trendName || row.label || row.title || '').toLowerCase().includes(brandLower);
1380
+ const descMatch = (row.trendDescription || row.description || row.summary || '').toLowerCase().includes(brandLower);
1381
+ const brandNamesArr = typeof row.brandNames === 'string' ? row.brandNames.split('|').map((s) => s.trim()).filter(Boolean) : (Array.isArray(row.brandNames) ? row.brandNames : []);
1382
+ const brandNamesMatch = brandNamesArr.some((b) => b.toLowerCase().includes(brandLower));
1383
+ const evidenceBrandMatch = (row.evidence || []).some((e) => (() => { const ebn = typeof e.brandNames === 'string' ? e.brandNames.split('|').map((s) => s.trim()).filter(Boolean) : (Array.isArray(e.brandNames) ? e.brandNames : []); return ebn.some((b) => b.toLowerCase().includes(brandLower)); })() ||
1384
+ (e.title || '').toLowerCase().includes(brandLower) ||
1385
+ (e.snippet || e.summary || '').toLowerCase().includes(brandLower));
1386
+ // Tier 1: Direct brand mention — high confidence
1387
+ const directMatch = nameMatch || descMatch || brandNamesMatch || evidenceBrandMatch;
1388
+ // Tier 2: Semantic relevance — the search used use_semantic:true,
1389
+ // so high-scoring results are topically relevant even without a literal brand mention.
1390
+ // This prevents discarding trends like "Closed-Loop Textiles" when searching for "Patagonia".
1391
+ const semanticMatch = (row.signal_score || row.score || 0) >= 60;
1392
+ if (!directMatch && !semanticMatch)
1393
+ return false;
1394
+ // Tag row so competitor extraction only runs on direct matches
1395
+ row._directBrandMatch = directMatch;
1396
+ return true;
1397
+ });
1398
+ if (brandRows.length === 0)
1399
+ continue;
1400
+ let graphEvCount = 0;
1401
+ for (const row of brandRows) {
1402
+ allTrends.push({
1403
+ trend_name: row.trendName || row.label || row.title,
1404
+ trend_description: row.trendDescription || row.description || row.summary || '',
1405
+ graphId,
1406
+ graphName,
1407
+ signal_score: row.signal_score || null,
1408
+ lifecycle: computeLifecycle(row),
1409
+ momentum: computeMomentum(row),
1410
+ evidence_count: row.evidence_count || row.evidenceCount || 0,
1411
+ node_id: row.node_id || row.trendId,
1412
+ _use_this_graphId: row._use_this_graphId || graphId,
1413
+ });
1414
+ if (includeEvidence && row.evidence) {
1415
+ for (const ev of row.evidence) {
1416
+ const evBrandsRaw = typeof ev.brandNames === 'string' ? ev.brandNames.split('|').map((s) => s.trim()).filter(Boolean) : (Array.isArray(ev.brandNames) ? ev.brandNames : []);
1417
+ const evText = `${ev.title || ''} ${ev.snippet || ev.summary || ''}`.toLowerCase();
1418
+ const evMentionsBrand = evBrandsRaw.some((b) => b.toLowerCase().includes(brandLower)) || evText.includes(brandLower);
1419
+ if (!evMentionsBrand)
1420
+ continue;
1421
+ allEvidence.push({
1422
+ title: ev.title,
1423
+ excerpt: ev.snippet || ev.summary || '',
1424
+ source_url: ev.sourceUrl,
1425
+ image_url: ev.imageUrl || null,
1426
+ published_at: ev.publishedAt || null,
1427
+ category: ev.evidenceType || ev.contentType || ev.category || 'Case Study',
1428
+ place: ev.place || null,
1429
+ graphId,
1430
+ graphName,
1431
+ brands_mentioned: evBrandsRaw,
1432
+ linked_trend: row.trendName || row.label || row.title,
1433
+ formatted_citation: ev.formatted_citation || (ev.title && ev.sourceUrl ? `[${ev.title}](${ev.sourceUrl})` : ev.title || ''),
1434
+ speaker_name: ev.speakerName || ev.speaker_name || null,
1435
+ speaker_title: ev.speakerTitle || ev.speaker_title || null,
1436
+ });
1437
+ graphEvCount++;
1438
+ // Only count competitors from DIRECT brand matches, not semantic matches
1439
+ if (row._directBrandMatch) {
1440
+ for (const b of evBrandsRaw) {
1441
+ const bLower = b.toLowerCase();
1442
+ if (bLower !== brandLower && !bLower.includes(brandLower) && !brandLower.includes(bLower)) {
1443
+ if (graphId.includes('earnings') || graphId.includes('finance'))
1444
+ continue;
1445
+ competitorCounts[b] = (competitorCounts[b] || 0) + 1;
1446
+ if (!competitorGraphs[b])
1447
+ competitorGraphs[b] = new Set();
1448
+ competitorGraphs[b].add(graphId);
1449
+ }
1450
+ }
1451
+ }
1452
+ }
1453
+ }
1454
+ // Only count trend-level brands as competitors for DIRECT brand matches
1455
+ if (row._directBrandMatch) {
1456
+ const rowBrands = typeof row.brandNames === 'string' ? row.brandNames.split('|').map((s) => s.trim()).filter(Boolean) : (Array.isArray(row.brandNames) ? row.brandNames : []);
1457
+ for (const b of rowBrands) {
1458
+ const bLower = b.toLowerCase();
1459
+ if (bLower !== brandLower && !bLower.includes(brandLower) && !brandLower.includes(bLower)) {
1460
+ competitorCounts[b] = (competitorCounts[b] || 0) + 1;
1461
+ if (!competitorGraphs[b])
1462
+ competitorGraphs[b] = new Set();
1463
+ competitorGraphs[b].add(graphId);
1464
+ }
1465
+ }
1466
+ }
1467
+ }
1468
+ graphPresence[graphId] = {
1469
+ graphId,
1470
+ graphName,
1471
+ graphType: graph.graph_type,
1472
+ trendCount: brandRows.length,
1473
+ evidenceCount: graphEvCount,
1474
+ };
1475
+ }
1476
+ }
1477
+ }
1478
+ // Deduplicate trends
1479
+ const seenTrends = new Set();
1480
+ const uniqueTrends = allTrends.filter(t => {
1481
+ const key = `${t.graphId}:${t.trend_name}`;
1482
+ if (seenTrends.has(key))
1483
+ return false;
1484
+ seenTrends.add(key);
1485
+ return true;
1486
+ });
1487
+ uniqueTrends.sort((a, b) => (b.signal_score || 0) - (a.signal_score || 0));
1488
+ // Deduplicate, filter stale, and limit evidence
1489
+ const EVIDENCE_STALE_MONTHS = 18;
1490
+ const evidenceCutoff = new Date();
1491
+ evidenceCutoff.setMonth(evidenceCutoff.getMonth() - EVIDENCE_STALE_MONTHS);
1492
+ const seenEvidence = new Set();
1493
+ const uniqueEvidence = allEvidence
1494
+ .filter(e => {
1495
+ const key = e.source_url || e.title;
1496
+ if (!key || seenEvidence.has(key))
1497
+ return false;
1498
+ seenEvidence.add(key);
1499
+ // Drop evidence older than 18 months
1500
+ if (e.published_at) {
1501
+ const d = new Date(e.published_at);
1502
+ if (!isNaN(d.getTime()) && d < evidenceCutoff)
1503
+ return false;
1504
+ }
1505
+ return true;
1506
+ })
1507
+ .sort((a, b) => {
1508
+ const aDate = a.published_at ? new Date(a.published_at).getTime() : 0;
1509
+ const bDate = b.published_at ? new Date(b.published_at).getTime() : 0;
1510
+ return bDate - aDate;
1511
+ })
1512
+ .slice(0, maxEv * Math.max(Object.keys(graphPresence).length, 1));
1513
+ // Filter out platforms/marketplaces — these appear in evidence as channels, not competitors
1514
+ const PLATFORM_BLOCKLIST = new Set([
1515
+ 'Meituan', 'Taobao', 'Alibaba', 'JD.com', 'Tmall', 'Pinduoduo', 'Shopee',
1516
+ 'Amazon', 'eBay', 'Etsy', 'Shopify', 'Walmart', 'Target',
1517
+ 'Google', 'Apple', 'Meta', 'Microsoft', 'OpenAI',
1518
+ 'Instagram', 'TikTok', 'YouTube', 'Snapchat', 'Pinterest', 'X', 'Twitter', 'Reddit', 'Substack',
1519
+ 'Spotify', 'Netflix', 'Disney+', 'Hulu',
1520
+ 'Uber', 'Lyft', 'DoorDash', 'Instacart',
1521
+ 'WeChat', 'WhatsApp', 'Telegram', 'LINE',
1522
+ 'Stripe', 'PayPal', 'Square', 'Klarna',
1523
+ ]);
1524
+ const filteredCompetitorCounts = Object.fromEntries(Object.entries(competitorCounts).filter(([name]) => !PLATFORM_BLOCKLIST.has(name)));
1525
+ // Build competitive context — sort by shared DOMAIN graph overlap
1526
+ const brandGraphSet = new Set(Object.keys(graphPresence));
1527
+ const domainGraphIds = new Set(getDomainGraphIds());
1528
+ const competitors = Object.entries(filteredCompetitorCounts)
1529
+ .sort(([nameA, countA], [nameB, countB]) => {
1530
+ const aGraphs = competitorGraphs[nameA] ? [...competitorGraphs[nameA]] : [];
1531
+ const bGraphs = competitorGraphs[nameB] ? [...competitorGraphs[nameB]] : [];
1532
+ // Weight: domain graph overlap = 2 points, expert graph overlap = 1 point
1533
+ const scoreA = aGraphs.filter(g => brandGraphSet.has(g)).reduce((s, g) => s + (domainGraphIds.has(g) ? 2 : 1), 0);
1534
+ const scoreB = bGraphs.filter(g => brandGraphSet.has(g)).reduce((s, g) => s + (domainGraphIds.has(g) ? 2 : 1), 0);
1535
+ // Primary: weighted graph overlap score
1536
+ if (scoreB !== scoreA)
1537
+ return scoreB - scoreA;
1538
+ // Secondary: co-occurrence count
1539
+ return countB - countA;
1540
+ })
1541
+ .slice(0, 15)
1542
+ .map(([name, count]) => ({
1543
+ brand: name,
1544
+ co_occurrences: count,
1545
+ graphIds: competitorGraphs[name] ? [...competitorGraphs[name]] : [],
1546
+ }));
1547
+ const crossGraphPresence = Object.values(graphPresence)
1548
+ .sort((a, b) => b.evidenceCount - a.evidenceCount);
1549
+ // Activity timeline
1550
+ const quarterCounts = {};
1551
+ for (const ev of uniqueEvidence) {
1552
+ if (ev.published_at) {
1553
+ const d = new Date(ev.published_at);
1554
+ const q = `${d.getFullYear()}-Q${Math.ceil((d.getMonth() + 1) / 3)}`;
1555
+ quarterCounts[q] = (quarterCounts[q] || 0) + 1;
1556
+ }
1557
+ }
1558
+ const activityTimeline = Object.entries(quarterCounts)
1559
+ .sort(([a], [b]) => b.localeCompare(a))
1560
+ .map(([period, count]) => ({ period, count }));
1561
+ // Evidence by type
1562
+ const evidenceByType = {};
1563
+ for (const ev of uniqueEvidence) {
1564
+ const cat = ev.category || 'Case Study';
1565
+ evidenceByType[cat] = (evidenceByType[cat] || 0) + 1;
1566
+ }
1567
+ // Lifecycle distribution
1568
+ const lifecycleDist = {};
1569
+ for (const t of uniqueTrends) {
1570
+ lifecycleDist[t.lifecycle] = (lifecycleDist[t.lifecycle] || 0) + 1;
1571
+ }
1572
+ // Evidence velocity
1573
+ const sortedTimeline = [...activityTimeline].sort((a, b) => b.period.localeCompare(a.period));
1574
+ const currentQ = sortedTimeline[0]?.count || 0;
1575
+ const prevQ = sortedTimeline[1]?.count || 0;
1576
+ const velocityTrend = currentQ > prevQ ? 'accelerating' : currentQ === prevQ ? 'stable' : 'decelerating';
1577
+ // Build profile
1578
+ const profile = {
1579
+ brand: brandName,
1580
+ _data_source: usedCypherEndpoint ? 'cypher-single-query' : 'multi-graph-search',
1581
+ summary: {
1582
+ total_evidence_items: uniqueEvidence.length,
1583
+ total_trends_connected: uniqueTrends.length,
1584
+ graphs_present_in: crossGraphPresence.map(g => g.graphName),
1585
+ evidence_velocity: {
1586
+ current_quarter: currentQ,
1587
+ previous_quarter: prevQ,
1588
+ trend: velocityTrend,
1589
+ },
1590
+ evidence_by_type: evidenceByType,
1591
+ lifecycle_distribution: lifecycleDist,
1592
+ },
1593
+ trend_footprint: uniqueTrends,
1594
+ evidence_items: includeEvidence ? uniqueEvidence : undefined,
1595
+ competitive_context: {
1596
+ co_occurring_brands: competitors,
1597
+ note: 'Brands that appear in the same evidence articles or trend contexts as ' + brandName,
1598
+ },
1599
+ cross_graph_presence: crossGraphPresence,
1600
+ activity_timeline: activityTimeline,
1601
+ _attribution: `Brand Intelligence Profile for ${brandName} — aggregated from ${crossGraphPresence.length} knowledge graph${crossGraphPresence.length !== 1 ? 's' : ''} on Fodda`,
1602
+ supplemental_signals: null,
1603
+ suggested_next_prompts: [
1604
+ {
1605
+ label: `vs ${competitors[0]?.brand || 'competitors'}`,
1606
+ prompt: `Compare ${brandName}'s innovation activity with ${competitors[0]?.brand || 'its top competitor'}. Which trends are they both pursuing and where do they diverge?`,
1607
+ type: 'competitive_comparison',
1608
+ },
1609
+ {
1610
+ label: 'Trend deep dive',
1611
+ prompt: `Show me the evidence behind ${brandName}'s strongest trend: "${uniqueTrends[0]?.trend_name || 'top trend'}"`,
1612
+ type: 'deeper_dive',
1613
+ },
1614
+ {
1615
+ label: 'Market signals',
1616
+ prompt: `Pull Google Trends and Amazon data for ${brandName} to validate the innovation signals from the knowledge graphs`,
1617
+ type: 'supplemental_validation',
1618
+ },
1619
+ ].filter(p => uniqueTrends.length > 0),
1620
+ _render_instructions: buildRenderInstructions({
1621
+ hasWidget: true,
1622
+ hasPrompts: true,
1623
+ hasEvidence: uniqueEvidence.length > 0,
1624
+ graphWebpageUrls: collectGraphWebpageUrls(Object.keys(graphPresence)),
1625
+ }),
1626
+ };
1627
+ const topCompetitors = competitors.slice(0, 2).map(c => c.brand);
1628
+ const comparisonQuery = [brandName, ...topCompetitors].join(',');
1629
+ // Wikipedia disambiguation: map brand names to canonical article titles
1630
+ const wikiDisambig = { 'Nike': 'Nike, Inc.', 'Apple': 'Apple Inc.', 'Amazon': 'Amazon (company)', 'Meta': 'Meta Platforms', 'Target': 'Target Corporation' };
1631
+ const wikiArticles = [brandName, ...topCompetitors].map(b => wikiDisambig[b] || b).join(',');
1632
+ const [googleTrendsResult, wikipediaResult, amazonResult, beaResult, earningsResult] = await Promise.allSettled([
1633
+ foddaRequest('GET', `/v1/supplemental/google-trends?query=${encodeURIComponent(comparisonQuery)}&geo=US&timeframe=today+12-m`, apiKey, resolveUserId(userId, uid)),
1634
+ foddaRequest('GET', `/v1/supplemental/wikipedia/pageviews?articles=${encodeURIComponent(wikiArticles)}&period=monthly`, apiKey, resolveUserId(userId, uid)),
1635
+ amazonPromise,
1636
+ censusPromise,
1637
+ foddaRequest('GET', `/v1/supplemental/earnings/snapshot?brand=${encodeURIComponent(brandName)}&limit=5`, apiKey, resolveUserId(userId, uid)),
1638
+ ]);
1639
+ // Unwrap .snapshot nesting — supplemental API wraps actual data inside .snapshot alongside metadata
1640
+ const unwrapSnapshot = (raw) => raw?.snapshot || raw;
1641
+ profile.supplemental_signals = {
1642
+ google_trends: googleTrendsResult.status === 'fulfilled' ? unwrapSnapshot(googleTrendsResult.value) : null,
1643
+ wikipedia: wikipediaResult.status === 'fulfilled' ? unwrapSnapshot(wikipediaResult.value) : null,
1644
+ amazon: amazonResult.status === 'fulfilled' ? unwrapSnapshot(amazonResult.value) : null,
1645
+ census_retail: beaResult.status === 'fulfilled' ? unwrapSnapshot(beaResult.value) : null,
1646
+ };
1647
+ // ── Earnings Intelligence (for publicly traded brands) ──
1648
+ const earningsRaw = earningsResult.status === 'fulfilled' ? earningsResult.value : null;
1649
+ const earningsSnapshot = earningsRaw?.snapshot || earningsRaw; // unwrap .snapshot if present
1650
+ const earningsItems = earningsSnapshot?.results || earningsSnapshot?.earnings || (Array.isArray(earningsSnapshot) ? earningsSnapshot : []);
1651
+ profile.earningsIntelligence = earningsItems.length > 0 ? earningsItems : undefined;
1652
+ const widget = await renderBrandWidget(profile);
1653
+ const EDITORIAL_INSTRUCTION = widget.open_slots.length === 0
1654
+ ? `── BRAND WIDGET: READY ──\nThe widget is fully populated. Call show_widget with the widget_html as-is. Do NOT modify the HTML.`
1655
+ : `── BRAND WIDGET: EDITORIAL SLOTS ──\nThe widget_html is mostly complete. Fill these remaining slot(s): ${widget.open_slots.join(', ')}\n\n${widget.open_slots.includes('ANALYSIS_HTML') ? '{{ANALYSIS_HTML}} — Write 3-5 paragraphs of strategic analysis using <p> tags. Cover: strongest signal and why, gaps or emerging opportunities, competitive positioning, and what to watch next. Use <strong> for key terms.' : ''}\n\nEDITORIAL CONTEXT:\n${JSON.stringify(widget.editorial_context, null, 2)}\n\nCRITICAL: ALL output must go INSIDE the widget slots. Do NOT redesign, restyle, or add new elements.\nAfter filling slots, pass the complete HTML to show_widget.\n`;
1656
+ storeWidget(widget.widget_html);
1657
+ return { profile, widget, EDITORIAL_INSTRUCTION };
1658
+ };
1659
+ server.tool('brand_tracker', 'Build a complete Brand Intelligence Profile by searching ALL knowledge graphs for a specific brand. Returns trend footprint (which trends the brand appears in), competitive landscape (co-occurring brands ranked by overlap), cross-graph presence, evidence timeline, lifecycle distribution, and bundled supplemental signals (Google Trends, Wikipedia, Amazon, earnings). Use when the query is about a specific company or brand — "What is Nike doing?", "Patagonia\'s innovation strategy", "How is Apple positioned?". This aggregates intelligence that would require dozens of separate web searches to assemble.', {
1660
+ brand_name: z.string().describe("The brand name to look up (e.g. 'Nike', 'Adidas', 'Apple'). Case-insensitive."),
1661
+ userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
1662
+ graph_ids: z.array(z.string()).optional().describe('Optional: specific graph IDs to search. If omitted, searches ALL accessible graphs.'),
1663
+ include_evidence: z.boolean().optional().describe('If true (default), include individual evidence items. Set to false for summary-only.'),
1664
+ max_evidence: z.number().optional().describe('Maximum evidence items per graph. Default: 10. Max: 25.'),
1665
+ }, { title: 'Brand Intelligence Profile', readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ brand_name, userId: uid, graph_ids, include_evidence, max_evidence }) => {
1666
+ try {
1667
+ // Log query to Questions table (fire-and-forget, before cache)
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;
1673
+ const { widget, EDITORIAL_INSTRUCTION } = await executeBrandTracker(brand_name, uid, graph_ids, include_evidence, max_evidence);
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;
1678
+ return { content: [
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 },
1680
+ { type: 'text', text: EDITORIAL_INSTRUCTION },
1681
+ ] };
1682
+ }
1683
+ catch (err) {
1684
+ const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
1685
+ if (trialResult)
1686
+ return trialResult;
1687
+ const msg = err.response?.data?.error?.message || err.response?.data?.message || err.message;
1688
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
1689
+ }
1690
+ });
1691
+ // --- get_supplemental_context (Unified Supplemental Endpoint) ---
1692
+ // Replaces 21 individual supplemental tools with a single call.
1693
+ // The API routes to 6-10 relevant sources based on query + domain,
1694
+ // queries them in parallel, and returns a consolidated response.
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. 5 API calls per standalone use.', {
1696
+ query: z.string().describe("The topic or query to get supplemental data for (e.g., 'sustainable packaging', 'tequila spirits market', 'Gen Z beauty')"),
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."),
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."),
1699
+ graph_ids: z.array(z.string()).optional().describe("Graph IDs from prior search results — helps refine domain inference."),
1700
+ userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
1701
+ }, { title: 'Get Market Context Data', readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ query, domain, brands, graph_ids, userId: uid }) => {
1702
+ try {
1703
+ // Log query to Questions table (fire-and-forget, before cache)
1704
+ logUserQuery(query, 'supplemental_context');
1705
+ const body = { query };
1706
+ if (domain)
1707
+ body.domain = domain;
1708
+ if (brands?.length)
1709
+ body.brands = brands;
1710
+ if (graph_ids?.length)
1711
+ body.graph_ids = graph_ids;
1712
+ const jobId = crypto.randomUUID();
1713
+ activeSupplementalJobs.set(jobId, { status: 'RUNNING', result: null, error: null });
1714
+ // Run fetch in the background
1715
+ (async () => {
1716
+ try {
1717
+ const data = await foddaRequest('POST', '/v1/supplemental/context', apiKey, resolveUserId(userId, uid), body);
1718
+ // ── Query-level billing ──
1719
+ chargeQuery({ queryTypeCode: 'standalone_supplemental', apiKey, userId: resolveUserId(userId, uid), query, foddaRequest, spt: sptCtx?.token })
1720
+ .catch(e => console.error('[supplemental] chargeQuery failed:', e.message));
1721
+ activeSupplementalJobs.set(jobId, { status: 'COMPLETE', result: JSON.stringify(data, null, 2) });
1722
+ }
1723
+ catch (err) {
1724
+ const errMsg = err.response?.data?.error?.message || err.response?.data?.message || err.message || 'Unknown error';
1725
+ activeSupplementalJobs.set(jobId, { status: 'FAILED', error: errMsg });
1726
+ }
1727
+ })();
1728
+ return {
1729
+ content: [{
1730
+ type: 'text',
1731
+ text: `Supplemental data gathering started! The server is collecting context from up to 15 external sources in parallel. Job ID: ${jobId}\n\nIMPORTANT: You must use the check_supplemental_status tool with this Job ID to poll the status of the job and retrieve the data. Wait about 5-10 seconds before your first poll.`
1732
+ }]
1733
+ };
1734
+ }
1735
+ catch (err) {
1736
+ const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
1737
+ if (trialResult)
1738
+ return trialResult;
1739
+ return await handleAccessError(err, 'supplemental');
1740
+ }
1741
+ });
1742
+ // --- check_supplemental_status ---
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.', {
1744
+ job_id: z.string().describe('The Job ID returned by get_supplemental_context'),
1745
+ }, { title: 'Check Supplemental Status', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ job_id }) => {
1746
+ const job = activeSupplementalJobs.get(job_id);
1747
+ if (!job) {
1748
+ return { isError: true, content: [{ type: 'text', text: `Job ID ${job_id} not found. It may have expired or never existed.` }] };
1749
+ }
1750
+ if (job.status === 'RUNNING') {
1751
+ return { content: [{ type: 'text', text: `Job ${job_id} is still RUNNING. The server is waiting on external APIs. Please poll again in 5 seconds.` }] };
1752
+ }
1753
+ if (job.status === 'FAILED') {
1754
+ activeSupplementalJobs.delete(job_id); // cleanup
1755
+ return { isError: true, content: [{ type: 'text', text: `Job ${job_id} FAILED: ${job.error}` }] };
1756
+ }
1757
+ if (job.status === 'COMPLETE') {
1758
+ activeSupplementalJobs.delete(job_id); // cleanup
1759
+ return { content: [{ type: 'text', text: job.result }] };
1760
+ }
1761
+ return { isError: true, content: [{ type: 'text', text: `Unknown status for job ${job_id}` }] };
1762
+ });
1763
+ // --- get_domain_intelligence ---
1764
+ // Searches ALL PSFK curated domain graphs in parallel. Returns trends + bundled evidence.
1765
+ server.tool('get_domain_intelligence', "Search PSFK-curated domain graphs (retail, beauty, fashion, sports, consumer electronics, F&B) for trend intelligence with bundled evidence. No graph ID needed — searches all relevant domain graphs in parallel. Returns expert-curated trends with categorized evidence (statistics, case studies, analysis, interviews) and source attribution. Use for broad industry trend research, sector analysis, or when the query spans multiple consumer categories. Preferred over web search for trend-level intelligence because results are editorially structured, not algorithmically ranked.", {
1766
+ query: z.string().describe("Natural language search query (e.g., 'sustainable packaging trends', 'Gen Z beauty habits')"),
1767
+ limit: z.number().optional().describe('Max trends to return (default: 10, max: 50)'),
1768
+ include_evidence: z.boolean().optional().describe('Bundle evidence for each trend (default: true)'),
1769
+ max_evidence_per_trend: z.number().optional().describe('Evidence items per trend (default: 5, max: 20)'),
1770
+ min_score: z.number().optional().describe('Minimum relevance threshold (default: 0.6)'),
1771
+ userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
1772
+ }, { title: 'Search Domain Intelligence', readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ query, limit, include_evidence, max_evidence_per_trend, min_score, userId: uid }) => {
1773
+ try {
1774
+ const body = { query };
1775
+ if (limit !== undefined)
1776
+ body.limit = limit;
1777
+ if (include_evidence !== undefined)
1778
+ body.include_evidence = include_evidence;
1779
+ if (max_evidence_per_trend !== undefined)
1780
+ body.max_evidence_per_trend = max_evidence_per_trend;
1781
+ if (min_score !== undefined)
1782
+ body.min_score = min_score;
1783
+ // Log query to Questions table (fire-and-forget, before cache)
1784
+ logUserQuery(query, 'domain_intelligence');
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;
1789
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1790
+ }
1791
+ catch (err) {
1792
+ const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
1793
+ if (trialResult)
1794
+ return trialResult;
1795
+ return await handleAccessError(err, 'supplemental');
1796
+ }
1797
+ });
1798
+ // --- get_expert_intelligence ---
1799
+ // Searches ALL expert specialist graphs in parallel.
1800
+ server.tool('get_expert_intelligence', "Search specialist knowledge graphs built by named strategists and industry leaders — contains proprietary analysis, expert interviews, and high-density statistics not available via web search. No graph ID needed — searches all expert graphs in parallel. Use when the query requires specialist depth, named-expert perspectives, or strategic frameworks beyond mainstream coverage. Expert graphs cover domains like macro strategy, wayfinding, design innovation, SXSW insights, and sector-specific research reports.", {
1801
+ query: z.string().describe("Natural language search query (e.g., 'tequila spirits market', 'future of work')"),
1802
+ limit: z.number().optional().describe('Max trends to return (default: 10, max: 50)'),
1803
+ include_evidence: z.boolean().optional().describe('Bundle evidence for each trend (default: true)'),
1804
+ max_evidence_per_trend: z.number().optional().describe('Evidence items per trend (default: 5, max: 20)'),
1805
+ min_score: z.number().optional().describe('Minimum relevance threshold (default: 0.6)'),
1806
+ userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
1807
+ }, { title: 'Search Expert Intelligence', readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ query, limit, include_evidence, max_evidence_per_trend, min_score, userId: uid }) => {
1808
+ try {
1809
+ const body = { query };
1810
+ if (limit !== undefined)
1811
+ body.limit = limit;
1812
+ if (include_evidence !== undefined)
1813
+ body.include_evidence = include_evidence;
1814
+ if (max_evidence_per_trend !== undefined)
1815
+ body.max_evidence_per_trend = max_evidence_per_trend;
1816
+ if (min_score !== undefined)
1817
+ body.min_score = min_score;
1818
+ // Log query to Questions table (fire-and-forget, before cache)
1819
+ logUserQuery(query, 'expert_intelligence');
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;
1824
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1825
+ }
1826
+ catch (err) {
1827
+ const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
1828
+ if (trialResult)
1829
+ return trialResult;
1830
+ return await handleAccessError(err, 'supplemental');
1831
+ }
1832
+ });
1833
+ // --- get_report_intelligence ---
1834
+ // Searches ALL industry report graphs in parallel.
1835
+ server.tool('get_report_intelligence', "Search industry report knowledge graphs for published research findings, market forecasts, and quantitative projections from organizations like DHL, PwC, Delta, and specialist research firms. Returns structured findings with bundled evidence — not raw PDFs or summaries, but editorially extracted trend data with source attribution. No graph ID needed. Use for market sizing, competitive landscape analysis, and data-heavy research where published report intelligence is more authoritative than web search results.", {
1836
+ query: z.string().describe("Natural language search query (e.g., 'luxury resale market size', 'electric vehicle adoption rates')"),
1837
+ limit: z.number().optional().describe('Max trends to return (default: 10, max: 50)'),
1838
+ include_evidence: z.boolean().optional().describe('Bundle evidence for each trend (default: true)'),
1839
+ max_evidence_per_trend: z.number().optional().describe('Evidence items per trend (default: 5, max: 20)'),
1840
+ min_score: z.number().optional().describe('Minimum relevance threshold (default: 0.6)'),
1841
+ userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
1842
+ }, { title: 'Search Report Intelligence', readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ query, limit, include_evidence, max_evidence_per_trend, min_score, userId: uid }) => {
1843
+ try {
1844
+ const body = { query };
1845
+ if (limit !== undefined)
1846
+ body.limit = limit;
1847
+ if (include_evidence !== undefined)
1848
+ body.include_evidence = include_evidence;
1849
+ if (max_evidence_per_trend !== undefined)
1850
+ body.max_evidence_per_trend = max_evidence_per_trend;
1851
+ if (min_score !== undefined)
1852
+ body.min_score = min_score;
1853
+ // Log query to Questions table (fire-and-forget, before cache)
1854
+ logUserQuery(query, 'report_intelligence');
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;
1859
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1860
+ }
1861
+ catch (err) {
1862
+ const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
1863
+ if (trialResult)
1864
+ return trialResult;
1865
+ return await handleAccessError(err, 'supplemental');
1866
+ }
1867
+ });
1868
+ // --- search_statistics ---
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.", {
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."),
1871
+ query: z.string().describe("What data to search for (e.g., 'luxury resale market size', 'secondhand clothing sales volume', 'Gen Z spending behavior')"),
1872
+ limit: z.number().optional().describe('Max results to return (default: 10, max: 50)'),
1873
+ min_score: z.number().optional().describe('Minimum relevance threshold, 0-1 (default: 0.60). Use 0.60 for broad queries, 0.70+ only for precise data lookups.'),
1874
+ include_signals: z.boolean().optional().describe('Also include Signal nodes (case studies, brand examples). Default: false'),
1875
+ userId: z.string().optional().describe('Optional user identifier for trial usage tracking.')
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 }) => {
1877
+ try {
1878
+ // Log query to Questions table (fire-and-forget, before cache)
1879
+ logUserQuery(query, 'search_statistics', graph_id);
1880
+ const params = new URLSearchParams();
1881
+ params.set('query', query);
1882
+ if (limit !== undefined)
1883
+ params.set('limit', String(limit));
1884
+ if (min_score !== undefined)
1885
+ params.set('min_score', String(min_score));
1886
+ if (include_signals)
1887
+ params.set('include_signals', 'true');
1888
+ const path = `/v1/graphs/${graph_id}/statistics?${params.toString()}`;
1889
+ const data = await foddaRequest('GET', path, apiKey, resolveUserId(userId, uid));
1890
+ // Inject theme block for visualization branding
1891
+ if (data && typeof data === 'object') {
1892
+ data.theme = getFoddaTheme(graph_id);
1893
+ }
1894
+ const statsWithheld = await settleOrWithhold({ queryTypeCode: 'standalone_statistics', apiKey, userId: resolveUserId(userId, uid), query }, 'search_statistics');
1895
+ if (statsWithheld)
1896
+ return statsWithheld;
1897
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1898
+ }
1899
+ catch (err) {
1900
+ const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
1901
+ if (trialResult)
1902
+ return trialResult;
1903
+ return await handleAccessError(err, 'supplemental');
1904
+ }
1905
+ });
1906
+ // --- search_insights ---
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.', {
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."),
1909
+ query: z.string().describe("Natural language search query. E.g. 'expert views on Gen Z luxury' or 'resale market statistics'"),
1910
+ types: z.string().optional().describe("Comma-separated evidence types to search: metric, quote, interpretation, signal, or 'all' (default: 'metric,quote,interpretation')"),
1911
+ limit: z.number().optional().describe('Max results to return (default: 10, max: 50)'),
1912
+ min_score: z.number().optional().describe('Minimum relevance threshold 0-1 (default: 0.60). Use 0.60 for broad queries, 0.70+ for precise lookups.'),
1913
+ userId: z.string().optional().describe('Optional user identifier for trial usage tracking.')
1914
+ }, { title: 'Search Expert Insights', readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ graph_id, query, types, limit, min_score, userId: uid }) => {
1915
+ try {
1916
+ // Log query to Questions table (fire-and-forget, before cache)
1917
+ logUserQuery(query, 'search_insights', graph_id);
1918
+ const searchTypes = types || 'metric,quote,interpretation';
1919
+ const params = new URLSearchParams();
1920
+ params.set('query', query);
1921
+ params.set('types', searchTypes);
1922
+ if (limit !== undefined)
1923
+ params.set('limit', String(limit));
1924
+ if (min_score !== undefined)
1925
+ params.set('min_score', String(min_score));
1926
+ const path = `/v1/graphs/${graph_id}/statistics?${params.toString()}`;
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;
1931
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1932
+ }
1933
+ catch (err) {
1934
+ const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
1935
+ if (trialResult)
1936
+ return trialResult;
1937
+ return await handleAccessError(err, 'supplemental');
1938
+ }
1939
+ });
1940
+ // --- get_earnings_intelligence ---
1941
+ // Cross-company and industry-level earnings call intelligence.
1942
+ // For single-brand earnings, brand_tracker already includes earningsIntelligence.
1943
+ // This tool is for: multi-company comparisons, industry/sector filters, and explicit earnings queries.
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). 5 API calls per use.', {
1945
+ ticker: z.string().optional().describe("Company stock ticker (e.g., 'NKE', 'LVMUY', 'HLT'). At least one filter required."),
1946
+ brand: z.string().optional().describe("Brand name for fuzzy matching (e.g., 'Nike', 'Marriott')"),
1947
+ industry: z.string().optional().describe("Industry filter (e.g., 'hotels', 'sportswear', 'consumer electronics')"),
1948
+ sector: z.string().optional().describe("Sector filter (e.g., 'retail', 'technology', 'travel')"),
1949
+ search: z.string().optional().describe("Free text search in earnings summaries (e.g., 'labor costs', 'tariff guidance', 'AI investment')"),
1950
+ dateFrom: z.string().optional().describe("ISO date range start (e.g., '2025-01-01')"),
1951
+ dateTo: z.string().optional().describe("ISO date range end (e.g., '2026-06-01')"),
1952
+ limit: z.number().int().optional().describe('Max results to return (default 20, max 50)'),
1953
+ userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
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 }) => {
1955
+ try {
1956
+ // Log query to Questions table (fire-and-forget, before cache)
1957
+ logUserQuery(search || brand || ticker || industry || sector || 'earnings snapshot', 'earnings_intelligence');
1958
+ const params = new URLSearchParams();
1959
+ if (ticker)
1960
+ params.set('ticker', ticker);
1961
+ if (brand)
1962
+ params.set('brand', brand);
1963
+ if (industry)
1964
+ params.set('industry', industry);
1965
+ if (sector)
1966
+ params.set('sector', sector);
1967
+ if (search)
1968
+ params.set('search', search);
1969
+ if (dateFrom)
1970
+ params.set('dateFrom', dateFrom);
1971
+ if (dateTo)
1972
+ params.set('dateTo', dateTo);
1973
+ if (limit !== undefined)
1974
+ params.set('limit', String(Math.min(limit, 50)));
1975
+ const qs = params.toString();
1976
+ const earningsGuard = sptGuard('earnings_intelligence');
1977
+ if (earningsGuard)
1978
+ return earningsGuard;
1979
+ const data = await foddaRequest('GET', `/v1/supplemental/earnings/snapshot${qs ? '?' + qs : ''}`, apiKey, resolveUserId(userId, uid));
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;
1984
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1985
+ }
1986
+ catch (err) {
1987
+ const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
1988
+ if (trialResult)
1989
+ return trialResult;
1990
+ return await handleAccessError(err, 'supplemental');
1991
+ }
1992
+ });
1993
+ // --- get_earnings_divergence ---
1994
+ // Gaps between what analysts are concerned about and how management responds.
1995
+ // This is premium intelligence — surfaces deflection and narrative mismatches.
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." 5 API calls per use.', {
1997
+ sector: z.string().optional().describe("Sector filter (e.g., 'retail', 'technology', 'travel')"),
1998
+ industry: z.string().optional().describe("Industry filter (e.g., 'hotels', 'sportswear', 'luxury')"),
1999
+ search: z.string().optional().describe("Free text search (e.g., 'tariffs', 'AI capex', 'margin erosion')"),
2000
+ dateFrom: z.string().optional().describe("ISO date range start"),
2001
+ dateTo: z.string().optional().describe("ISO date range end"),
2002
+ limit: z.number().int().optional().describe('Max results to return (default 10, max 25)'),
2003
+ userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
2004
+ }, { title: 'Detect Earnings Call Divergence', readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ sector, industry, search, dateFrom, dateTo, limit, userId: uid }) => {
2005
+ try {
2006
+ // Log query to Questions table (fire-and-forget, before cache)
2007
+ logUserQuery(search || sector || industry || 'earnings divergence', 'earnings_divergence');
2008
+ const params = new URLSearchParams();
2009
+ if (sector)
2010
+ params.set('sector', sector);
2011
+ if (industry)
2012
+ params.set('industry', industry);
2013
+ if (search)
2014
+ params.set('search', search);
2015
+ if (dateFrom)
2016
+ params.set('dateFrom', dateFrom);
2017
+ if (dateTo)
2018
+ params.set('dateTo', dateTo);
2019
+ if (limit !== undefined)
2020
+ params.set('limit', String(Math.min(limit, 25)));
2021
+ const qs = params.toString();
2022
+ const divergenceGuard = sptGuard('earnings_intelligence');
2023
+ if (divergenceGuard)
2024
+ return divergenceGuard;
2025
+ const data = await foddaRequest('GET', `/v1/supplemental/earnings/divergence${qs ? '?' + qs : ''}`, apiKey, resolveUserId(userId, uid));
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
+ // --- update_user_profile ---
2040
+ 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.', {
2041
+ 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.'),
2042
+ 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.'),
2043
+ }, { title: 'Update Research Profile', readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, async ({ userContext, accountContext }) => {
2044
+ try {
2045
+ const body = {};
2046
+ if (userContext)
2047
+ body.userContext = String(userContext).slice(0, 2000);
2048
+ if (accountContext)
2049
+ body.accountContext = String(accountContext).slice(0, 2000);
2050
+ await foddaRequest('POST', '/v1/user/context', apiKey, userId, body);
2051
+ console.error(`[update_user_profile] Profile saved for ${userId}: userContext=${(userContext || '').length} chars, accountContext=${(accountContext || '').length} chars`);
2052
+ return {
2053
+ content: [{
2054
+ type: 'text',
2055
+ text: JSON.stringify({
2056
+ status: 'SAVED',
2057
+ message: 'Research profile updated. Future sessions will use this context to personalize responses.',
2058
+ }, null, 2)
2059
+ }]
2060
+ };
2061
+ }
2062
+ catch (err) {
2063
+ const msg = err.response?.data?.error?.message || err.response?.data?.message || err.message;
2064
+ console.error(`[update_user_profile] Error: ${msg}`);
2065
+ // Fail gracefully — don't break the conversation
2066
+ return {
2067
+ content: [{
2068
+ type: 'text',
2069
+ text: JSON.stringify({
2070
+ status: 'ERROR',
2071
+ message: 'Could not save profile right now. I\'ll use this context for the current session.',
2072
+ error: msg,
2073
+ }, null, 2)
2074
+ }]
2075
+ };
2076
+ }
2077
+ });
2078
+ // --- toggle_graph_preference ---
2079
+ server.tool('toggle_graph_preference', 'Enable or disable any knowledge graph, supplemental data source, or skill for the user. Use this when the user says "Turn off Paralogy", "Enable igloo", "Disable the economics data", or similar. The change is permanent until toggled again.', {
2080
+ 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.'),
2081
+ enabled: z.boolean().describe('true to enable (turn on), false to disable (turn off).'),
2082
+ user_email: z.string().optional().describe('Optional. Use ONLY when operating as an Admin on behalf of another user to specify their email.')
2083
+ }, { title: 'Toggle Graph or Skill', readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, async ({ target_id, enabled, user_email }) => {
2084
+ try {
2085
+ const body = { target_id, enabled };
2086
+ if (user_email)
2087
+ body.user_email = user_email;
2088
+ const result = await foddaRequest('POST', '/v1/user/preferences/toggle', apiKey, userId, body);
2089
+ return {
2090
+ content: [{
2091
+ type: 'text',
2092
+ text: JSON.stringify({
2093
+ status: 'SUCCESS',
2094
+ message: `Successfully ${enabled ? 'enabled' : 'disabled'} ${target_id}.`,
2095
+ disabled_graphs: result.disabled_graphs || [],
2096
+ }, null, 2)
2097
+ }]
2098
+ };
2099
+ }
2100
+ catch (err) {
2101
+ const msg = err.response?.data?.error?.message || err.response?.data?.message || err.message;
2102
+ return {
2103
+ content: [{
2104
+ type: 'text',
2105
+ text: JSON.stringify({
2106
+ status: 'ERROR',
2107
+ message: `Failed to ${enabled ? 'enable' : 'disable'} ${target_id}.`,
2108
+ error: msg,
2109
+ }, null, 2)
2110
+ }]
2111
+ };
2112
+ }
2113
+ });
2114
+ // --- send_feedback ---
2115
+ const FEEDBACK_CATEGORY_EMOJI = {
2116
+ feedback: '💬',
2117
+ bug: '🐛',
2118
+ feature_request: '✨',
2119
+ exit_reason: '🚪',
2120
+ complaint: '😤',
2121
+ };
2122
+ server.tool('send_feedback', 'Forward user feedback, feature requests, complaints, or exit reasons to the Fodda team via email and Slack. Call this whenever a user shares feedback — including when they want to leave, report a problem, or suggest an improvement.', {
2123
+ feedback: z.string().describe('The user\'s feedback, complaint, suggestion, or exit reason'),
2124
+ user_email: z.string().optional().describe('User\'s email if known (for follow-up)'),
2125
+ category: z.string().optional().describe("Category: 'feedback', 'bug', 'feature_request', 'exit_reason', 'complaint'"),
2126
+ }, { title: 'Send Feedback', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ feedback, user_email, category }) => {
2127
+ try {
2128
+ const userLabel = user_email || (userId !== 'anonymous' ? userId : 'anonymous trial user');
2129
+ const entryLabel = entryId ? ` (entry: ${entryId})` : '';
2130
+ const catLabel = category || 'general';
2131
+ const emoji = FEEDBACK_CATEGORY_EMOJI[catLabel] || '💬';
2132
+ // ── Slack alert (fire-and-forget) ──
2133
+ const slackText = [
2134
+ `<@U0AU49JG7AS> ${emoji} *User Feedback*`,
2135
+ `👤 ${userLabel}`,
2136
+ `📁 Category: ${catLabel}`,
2137
+ `📝 ${feedback}`,
2138
+ `→ Check if this needs a response or product action.`,
2139
+ ].join('\n');
2140
+ postToSlack(slackText).catch(() => { });
2141
+ // ── Resend email ──
2142
+ const resendKey = process.env.RESEND_API_KEY;
2143
+ if (!resendKey) {
2144
+ console.error('[send_feedback] RESEND_API_KEY not set — logging feedback locally');
2145
+ console.error(`[FEEDBACK] category=${catLabel} email=${userLabel} feedback=${feedback}`);
2146
+ return {
2147
+ content: [{
2148
+ type: 'text',
2149
+ text: JSON.stringify({ status: 'LOGGED', message: 'Thank you — your feedback has been recorded.' })
2150
+ }]
2151
+ };
2152
+ }
2153
+ const { Resend } = await import('resend');
2154
+ const resend = new Resend(resendKey);
2155
+ const subject = `[Fodda MCP Feedback] ${catLabel} from ${userLabel}`;
2156
+ await resend.emails.send({
2157
+ from: 'Fodda MCP <feedback@fodda.ai>',
2158
+ to: ['piers@fodda.ai'],
2159
+ subject,
2160
+ text: [
2161
+ `Category: ${catLabel}`,
2162
+ `User: ${userLabel}${entryLabel}`,
2163
+ `API Key: ${apiKey.substring(0, 15)}...`,
2164
+ `Date: ${new Date().toISOString()}`,
2165
+ '',
2166
+ 'Feedback:',
2167
+ feedback,
2168
+ ].join('\n'),
2169
+ });
2170
+ return {
2171
+ content: [{
2172
+ type: 'text',
2173
+ text: JSON.stringify({ status: 'SENT', message: 'Your feedback has been sent to the Fodda team. Thank you.' })
2174
+ }]
2175
+ };
2176
+ }
2177
+ catch (err) {
2178
+ console.error('[send_feedback] Error:', err.message);
2179
+ // Still log it even if sending fails
2180
+ console.error(`[FEEDBACK-FALLBACK] category=${category || 'general'} feedback=${feedback}`);
2181
+ return {
2182
+ content: [{
2183
+ type: 'text',
2184
+ text: JSON.stringify({ status: 'LOGGED', message: 'Thank you — your feedback has been recorded.' })
2185
+ }]
2186
+ };
2187
+ }
2188
+ });
2189
+ // --- sign_up_free_account ---
2190
+ const APP_BASE_URL = process.env.FODDA_APP_URL || 'https://app.fodda.ai';
2191
+ 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).', {
2192
+ email: z.string().describe('User\'s email address (required)'),
2193
+ name: z.string().optional().describe('User\'s full name (optional — collect conversationally after signup)'),
2194
+ job_title: z.string().optional().describe('User\'s job title (optional)'),
2195
+ company: z.string().optional().describe('User\'s company name (optional)'),
2196
+ }, { title: 'Create Base Account', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ email, name, job_title, company }) => {
2197
+ try {
2198
+ // Derive firstName from name or email prefix
2199
+ const firstName = name
2200
+ ? (name.split(' ')[0] || name)
2201
+ : (email.split('@')[0] || 'User').replace(/[._-]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
2202
+ const convertBody = {
2203
+ email,
2204
+ trialKey: apiKey, // the trial key that brought them in
2205
+ firstName,
2206
+ };
2207
+ const response = await axios.post(`${APP_BASE_URL}/api/account/trial-convert`, convertBody, { headers: { 'Content-Type': 'application/json' }, timeout: 10000 });
2208
+ if (response.data?.ok && !response.data?.alreadyExists) {
2209
+ return {
2210
+ content: [{
2211
+ type: 'text',
2212
+ text: JSON.stringify({
2213
+ status: 'UPGRADED',
2214
+ message: `I've created your Base account — you'll get ${response.data.monthlyTokens || 100} tokens/month. Check your email to confirm and then log in at https://app.fodda.ai to grab your MCP URL and update your connector.`,
2215
+ plan: response.data.plan || 'Base',
2216
+ monthly_token_limit: response.data.monthlyTokens || 100,
2217
+ graphId: response.data.graphId || null,
2218
+ accountId: response.data.accountId || null,
2219
+ }, null, 2)
2220
+ }]
2221
+ };
2222
+ }
2223
+ if (response.data?.alreadyExists) {
2224
+ return {
2225
+ content: [{
2226
+ type: 'text',
2227
+ text: JSON.stringify({
2228
+ status: 'EXISTING_ACCOUNT',
2229
+ message: `An account with ${email} already exists. Log in at https://app.fodda.ai to grab your MCP URL and update your connector.`,
2230
+ }, null, 2)
2231
+ }]
2232
+ };
2233
+ }
2234
+ // Unexpected response
2235
+ return {
2236
+ isError: true,
2237
+ content: [{
2238
+ type: 'text',
2239
+ text: JSON.stringify({ error: response.data?.message || 'Account creation failed. Please try again or visit https://app.fodda.ai' })
2240
+ }]
2241
+ };
2242
+ }
2243
+ catch (err) {
2244
+ const msg = err.response?.data?.message || err.message || 'Account creation failed.';
2245
+ console.error('[sign_up_free_account] Error:', msg);
2246
+ return {
2247
+ isError: true,
2248
+ content: [{
2249
+ type: 'text',
2250
+ text: JSON.stringify({
2251
+ error: msg,
2252
+ fallback: 'You can also sign up at https://app.fodda.ai to continue using Fodda.'
2253
+ })
2254
+ }]
2255
+ };
2256
+ }
2257
+ });
2258
+ // --- brainstorm_topic ---
2259
+ // Fourth MCP orchestration flow: Graph-native ideation via neighbor traversal.
2260
+ // Uses get_neighbors as the core mechanism to discover unexpected connections,
2261
+ // adjacent territories, and cross-domain links that text search wouldn't surface.
2262
+ server.tool('brainstorm_topic', 'Explore and brainstorm around a topic using knowledge graph connections. Unlike search (which finds what matches), this tool discovers what CONNECTS — adjacent trends, unexpected cross-domain links, key brands, and geographic hotspots. Use when the user wants to brainstorm, explore adjacencies, find inspiration, or understand the landscape around a topic. Returns a structured brainstorm map with territories to explore.', {
2263
+ query: z.string().describe("The topic or theme to brainstorm around (e.g., 'tequila', 'sustainable packaging', 'Gen Z beauty')"),
2264
+ depth: z.number().optional().describe('Traversal depth: 1 (immediate connections) or 2 (connections of connections). Default: 2. Use 1 for focused brainstorms, 2 for wider exploration.'),
2265
+ userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
2266
+ }, { title: 'Brainstorm & Explore Topic', readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ query, depth, userId: uid }) => {
2267
+ try {
2268
+ // Log query to Questions table (fire-and-forget, before cache)
2269
+ logUserQuery(query, 'brainstorm_topic');
2270
+ const resolvedUserId = resolveUserId(userId, uid);
2271
+ const traversalDepth = Math.min(depth || 2, 2);
2272
+ // ── Step 1: Find seed trends across all relevant graphs ──
2273
+ const relevantGraphs = getRelevantGraphs(query);
2274
+ const graphIdsToSearch = relevantGraphs.slice(0, 4).map(g => g.graph.graph_id);
2275
+ // Search up to 4 graphs in parallel for seed trends
2276
+ const searchPromises = graphIdsToSearch.map(async (gid) => {
2277
+ try {
2278
+ const body = { query, limit: 5, use_semantic: true, include_evidence: false };
2279
+ const res = await foddaRequest('POST', `/v1/graphs/${encodeURIComponent(gid)}/search`, apiKey, resolvedUserId, body);
2280
+ return (res?.rows || []).map((r) => ({ ...r, _source_graph: gid }));
2281
+ }
2282
+ catch {
2283
+ return [];
2284
+ }
2285
+ });
2286
+ const allResults = (await Promise.all(searchPromises)).flat();
2287
+ if (allResults.length === 0) {
2288
+ return {
2289
+ content: [{
2290
+ type: 'text',
2291
+ text: JSON.stringify({
2292
+ query,
2293
+ status: 'no_seeds',
2294
+ message: `Fodda's knowledge graphs don't have strong coverage on "${query}" yet. Try a broader topic or different angle.`,
2295
+ suggested_alternatives: [
2296
+ `Try broader: "${query.split(' ')[0]} trends"`,
2297
+ `Try adjacent: "innovation in ${query}"`,
2298
+ ]
2299
+ }, null, 2)
2300
+ }]
2301
+ };
2302
+ }
2303
+ // Take top 5 unique trends as seeds (deduplicated by title)
2304
+ const seen = new Set();
2305
+ const seeds = [];
2306
+ for (const r of allResults.sort((a, b) => (b.score || 0) - (a.score || 0))) {
2307
+ const key = (r.title || r.trendName || '').toLowerCase();
2308
+ if (!seen.has(key) && seeds.length < 5) {
2309
+ seen.add(key);
2310
+ seeds.push(r);
2311
+ }
2312
+ }
2313
+ // ── Step 2: Discover adjacent trends via semantic similarity ──
2314
+ // Uses /adjacent (pre-computed SEMANTICALLY_SIMILAR edges) instead of
2315
+ // /neighbors (relationship traversal) because expert graphs have flat
2316
+ // structures with no inter-node relationships.
2317
+ const adjacentPromises = seeds.map(async (seed) => {
2318
+ const graphId = seed._use_this_graphId || seed._source_graph;
2319
+ const nodeId = seed.node_id || seed.trendId;
2320
+ if (!graphId || !nodeId)
2321
+ return { seed, adjacent: [] };
2322
+ try {
2323
+ const params = new URLSearchParams({
2324
+ node_id: String(nodeId),
2325
+ min_score: '0.70',
2326
+ limit: '15',
2327
+ include_editorial: 'true',
2328
+ cross_graph: 'true', // Option B: discover trends from ANY graph
2329
+ });
2330
+ const res = await foddaRequest('GET', `/v1/graphs/${encodeURIComponent(graphId)}/adjacent?${params.toString()}`, apiKey, resolvedUserId);
2331
+ return { seed, graphId, adjacent: res?.adjacent || [] };
2332
+ }
2333
+ catch {
2334
+ return { seed, graphId, adjacent: [] };
2335
+ }
2336
+ });
2337
+ const adjacentResults = await Promise.all(adjacentPromises);
2338
+ // ── Step 3: Cluster discoveries ──
2339
+ const adjacentTrends = new Map();
2340
+ const brands = new Map();
2341
+ const locations = new Map();
2342
+ const seedTitles = new Set(seeds.map(s => (s.title || s.trendName || '').toLowerCase()));
2343
+ for (const { seed, adjacent } of adjacentResults) {
2344
+ const seedTitle = seed.title || seed.trendName || 'Unknown';
2345
+ for (const node of adjacent) {
2346
+ const name = node.trendName || node.name || node.title || '';
2347
+ const nameKey = name.toLowerCase();
2348
+ if (!name || seedTitles.has(nameKey))
2349
+ continue;
2350
+ if (!adjacentTrends.has(nameKey)) {
2351
+ adjacentTrends.set(nameKey, {
2352
+ name,
2353
+ node_id: node.node_id || node.trendId,
2354
+ graph_id: node.vertical || node.graph_id,
2355
+ score: node.similarity || node.score,
2356
+ relationship: node.editoriallyLinked ? 'EDITORIALLY_LINKED' : 'SEMANTICALLY_SIMILAR',
2357
+ connected_to: seedTitle,
2358
+ description: node.description || node.summary || '',
2359
+ cross_graph: node.vertical !== (seed._use_this_graphId || seed._source_graph),
2360
+ });
2361
+ }
2362
+ }
2363
+ }
2364
+ // ── Step 4: Build suggested next prompts from graph connections ──
2365
+ const topAdjacent = [...adjacentTrends.values()].slice(0, 10);
2366
+ const topBrands = [...brands.values()]
2367
+ .sort((a, b) => b.connections.length - a.connections.length)
2368
+ .slice(0, 8);
2369
+ const topLocations = [...locations.values()]
2370
+ .sort((a, b) => b.connections.length - a.connections.length)
2371
+ .slice(0, 5);
2372
+ // Generate graph-powered follow-up prompts
2373
+ const suggestedPrompts = [];
2374
+ if (topAdjacent.length > 0) {
2375
+ const surprise = topAdjacent.find(t => t.relationship === 'SEMANTICALLY_SIMILAR') || topAdjacent[0];
2376
+ suggestedPrompts.push(`How does "${query}" connect to "${surprise.name}"?`);
2377
+ }
2378
+ if (topBrands.length >= 2) {
2379
+ suggestedPrompts.push(`What are ${topBrands[0].name} and ${topBrands[1].name} doing in ${query}?`);
2380
+ }
2381
+ if (topAdjacent.length > 2) {
2382
+ suggestedPrompts.push(`Deep research: ${query} and ${topAdjacent[1].name}`);
2383
+ }
2384
+ if (topLocations.length > 0) {
2385
+ suggestedPrompts.push(`What's happening with ${query} in ${topLocations[0].name}?`);
2386
+ }
2387
+ if (topAdjacent.length > 4) {
2388
+ suggestedPrompts.push(`Brainstorm: ${topAdjacent[3].name}`);
2389
+ }
2390
+ // ── Step 5: Assemble brainstorm map ──
2391
+ const brainstormMap = {
2392
+ query,
2393
+ status: 'brainstorm_complete',
2394
+ _generated_by: 'brainstorm_topic',
2395
+ traversal_depth: traversalDepth,
2396
+ seed_trends: seeds.map(s => ({
2397
+ name: s.title || s.trendName,
2398
+ graph: s._use_this_graphId || s._source_graph,
2399
+ signal_score: s.signal_score || s.score,
2400
+ node_id: s.node_id || s.trendId,
2401
+ lifecycle: s.trendLifecycle || s.lifecycle,
2402
+ })),
2403
+ adjacent_territories: topAdjacent.map(t => ({
2404
+ name: t.name,
2405
+ relationship: t.relationship,
2406
+ connected_to: t.connected_to,
2407
+ description: t.description,
2408
+ graph_id: t.graph_id,
2409
+ node_id: t.node_id,
2410
+ })),
2411
+ key_brands: topBrands.map(b => ({
2412
+ name: b.name,
2413
+ appears_in: b.connections,
2414
+ cross_trend: b.connections.length > 1,
2415
+ })),
2416
+ geographic_hotspots: topLocations.map(l => ({
2417
+ location: l.name,
2418
+ connected_to: l.connections,
2419
+ })),
2420
+ brainstorm_stats: {
2421
+ seeds_found: seeds.length,
2422
+ graphs_searched: graphIdsToSearch.length,
2423
+ adjacent_trends_discovered: adjacentTrends.size,
2424
+ brands_identified: brands.size,
2425
+ locations_identified: locations.size,
2426
+ },
2427
+ suggested_next_prompts: suggestedPrompts,
2428
+ _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.',
2429
+ };
2430
+ // ── Query-level billing ──
2431
+ chargeQuery({ queryTypeCode: 'brainstorm', apiKey, userId: resolveUserId(userId, uid), query, foddaRequest, spt: sptCtx?.token })
2432
+ .catch(e => console.error('[brainstorm] chargeQuery failed:', e.message));
2433
+ return { content: [{ type: 'text', text: JSON.stringify(brainstormMap, null, 2) }] };
2434
+ }
2435
+ catch (err) {
2436
+ const msg = err.message || 'Brainstorm execution failed.';
2437
+ console.error('[brainstorm_topic] Error:', msg);
2438
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
2439
+ }
2440
+ });
2441
+ // --- generate_visual ---
2442
+ 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.', {
2443
+ chart_type: z.enum(['cultural_shifts', 'competitive_compass', 'trend_constellation', 'implication_ladder', 'innovation_pathway', 'opportunity_map']).describe('The type of visualization to generate'),
2444
+ 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}]}'),
2445
+ }, { title: 'Generate Visual', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ chart_type, data }) => {
2446
+ try {
2447
+ const { renderCulturalShifts, renderCompetitiveCompass, renderTrendConstellation, renderImplicationLadder, renderInnovationPathway, renderWhiteSpaceMap } = await import('./svgVisuals.js');
2448
+ const parsed = JSON.parse(data);
2449
+ let svg = '';
2450
+ switch (chart_type) {
2451
+ case 'cultural_shifts':
2452
+ svg = renderCulturalShifts(parsed.shifts || parsed);
2453
+ break;
2454
+ case 'competitive_compass':
2455
+ svg = renderCompetitiveCompass(parsed.brands || parsed, parsed.axes || { left: 'Traditional', right: 'Innovative', top: 'Premium', bottom: 'Mass' });
2456
+ break;
2457
+ case 'trend_constellation':
2458
+ svg = renderTrendConstellation(parsed.trends || parsed, parsed.connections || []);
2459
+ break;
2460
+ case 'implication_ladder':
2461
+ svg = renderImplicationLadder(parsed);
2462
+ break;
2463
+ case 'innovation_pathway':
2464
+ svg = renderInnovationPathway(parsed);
2465
+ break;
2466
+ case 'opportunity_map':
2467
+ svg = renderWhiteSpaceMap(parsed.items || parsed, parsed.x_label, parsed.y_label);
2468
+ break;
2469
+ }
2470
+ if (!svg) {
2471
+ return { isError: true, content: [{ type: 'text', text: 'Failed to generate visual. Check data format.' }] };
2472
+ }
2473
+ return {
2474
+ content: [{
2475
+ type: 'text',
2476
+ text: svg
2477
+ }]
2478
+ };
2479
+ }
2480
+ catch (err) {
2481
+ console.error('[generate_visual] Error:', err.message);
2482
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: err.message }) }] };
2483
+ }
2484
+ });
2485
+ // --- manage_scheduled_reports ---
2486
+ server.tool('manage_scheduled_reports', 'Create, list, cancel, update, pause, or resume scheduled intelligence briefings. Users can set up autonomous research that runs weekly (Mondays) or daily (Mon-Fri) at 9am in their timezone, delivered via email or Slack. Costs 20 API calls per run. Supports topic research or brand intelligence report types.', {
2487
+ action: z.enum(['create', 'list', 'cancel', 'update', 'pause', 'resume']),
2488
+ query: z.string().optional().describe('For "create": the research query to run'),
2489
+ email: z.string().optional().describe('Email address to deliver reports to'),
2490
+ slack_webhook: z.string().optional().describe('Optional Slack webhook URL for delivery'),
2491
+ graphs: z.array(z.string()).optional().describe('Specific graph IDs to search. Default: all accessible'),
2492
+ schedule_id: z.string().optional().describe('For cancel/update/pause/resume: the schedule ID'),
2493
+ cadence: z.enum(['weekly', 'daily']).optional()
2494
+ .describe('weekly or daily (Mon-Fri). Default: weekly'),
2495
+ timezone: z.enum(['london', 'new_york', 'san_francisco', 'sydney']).optional()
2496
+ .describe('Delivery timezone for 9am delivery. Default: new_york'),
2497
+ report_type: z.enum(['topic_research', 'brand_intelligence']).optional()
2498
+ .describe('topic_research for sector trends, brand_intelligence for competitive tracking'),
2499
+ brands: z.array(z.string()).optional()
2500
+ .describe('For brand_intelligence: brand names to track (e.g., ["Nike", "Patagonia"])'),
2501
+ }, { 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 }) => {
2502
+ try {
2503
+ if (action === 'create') {
2504
+ if (!query)
2505
+ return { isError: true, content: [{ type: 'text', text: 'A research query is required to create a schedule.' }] };
2506
+ if (!email)
2507
+ return { isError: true, content: [{ type: 'text', text: 'An email address is required for report delivery.' }] };
2508
+ const day_of_week = cadence === 'daily' ? 'weekdays' : 'monday';
2509
+ const body = {
2510
+ query,
2511
+ cadence: cadence || 'weekly',
2512
+ day_of_week,
2513
+ hour_utc: 9, // Always 9am local
2514
+ timezone: timezone || 'new_york',
2515
+ report_type: report_type || 'topic_research',
2516
+ brands: brands || [],
2517
+ graphs: graphs || [],
2518
+ delivery: { email, slack_webhook, format: 'markdown' },
2519
+ name: query.substring(0, 80),
2520
+ };
2521
+ const result = await foddaRequest('POST', '/v1/research/schedules', apiKey, resolveUserId('', ''), body);
2522
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) + '\n\n✅ Briefing created! Your first report will arrive within 24 hours as a preview.' }] };
2523
+ }
2524
+ else if (action === 'list') {
2525
+ const result = await foddaRequest('GET', '/v1/research/schedules', apiKey, resolveUserId('', ''));
2526
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
2527
+ }
2528
+ else if (action === 'cancel') {
2529
+ if (!schedule_id)
2530
+ return { isError: true, content: [{ type: 'text', text: 'A schedule_id is required to cancel.' }] };
2531
+ const result = await foddaRequest('POST', `/v1/research/schedules/${encodeURIComponent(schedule_id)}/cancel`, apiKey, resolveUserId('', ''), {});
2532
+ return { content: [{ type: 'text', text: '✅ Schedule cancelled. Already-consumed API calls are not refunded.' }] };
2533
+ }
2534
+ else if (action === 'pause') {
2535
+ if (!schedule_id)
2536
+ return { isError: true, content: [{ type: 'text', text: 'A schedule_id is required to pause.' }] };
2537
+ const result = await foddaRequest('PATCH', `/v1/research/schedules/${encodeURIComponent(schedule_id)}`, apiKey, resolveUserId('', ''), { status: 'paused' });
2538
+ return { content: [{ type: 'text', text: '⏸️ Briefing paused. Say "resume my briefing" to restart.' }] };
2539
+ }
2540
+ else if (action === 'resume') {
2541
+ if (!schedule_id)
2542
+ return { isError: true, content: [{ type: 'text', text: 'A schedule_id is required to resume.' }] };
2543
+ const result = await foddaRequest('PATCH', `/v1/research/schedules/${encodeURIComponent(schedule_id)}`, apiKey, resolveUserId('', ''), { status: 'active' });
2544
+ return { content: [{ type: 'text', text: `✅ Briefing resumed! Next delivery: ${result.next_run}` }] };
2545
+ }
2546
+ else if (action === 'update') {
2547
+ if (!schedule_id)
2548
+ return { isError: true, content: [{ type: 'text', text: 'A schedule_id is required to update.' }] };
2549
+ const body = {};
2550
+ if (cadence)
2551
+ body.cadence = cadence;
2552
+ if (timezone)
2553
+ body.timezone = timezone;
2554
+ if (email)
2555
+ body.delivery = { email };
2556
+ if (brands)
2557
+ body.brands = brands;
2558
+ if (report_type)
2559
+ body.report_type = report_type;
2560
+ const result = await foddaRequest('PATCH', `/v1/research/schedules/${encodeURIComponent(schedule_id)}`, apiKey, resolveUserId('', ''), body);
2561
+ return { content: [{ type: 'text', text: `✅ Briefing updated: ${result.changes.join(', ')}. Next delivery: ${result.next_run}` }] };
2562
+ }
2563
+ return { isError: true, content: [{ type: 'text', text: 'Unknown action.' }] };
2564
+ }
2565
+ catch (err) {
2566
+ console.error('[manage_scheduled_reports] Error:', err.message);
2567
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: err.message }) }] };
2568
+ }
2569
+ });
2570
+ // --- read_url ---
2571
+ 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.', {
2572
+ url: z.string().describe('The URL to read and extract content from'),
2573
+ userId: z.string().optional().describe('Optional user identifier for usage tracking.')
2574
+ }, { title: 'Read URL Content', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, async ({ url, userId: uid }) => {
2575
+ try {
2576
+ const result = await waverunnerRequest('search', // Uses standard search pool
2577
+ 1, apiKey, resolveUserId(userId, uid), {
2578
+ model: 'gemini-2.5-flash',
2579
+ input: [
2580
+ { type: 'text', text: `Extract and return the full text content from this URL. Return ONLY the extracted text, preserving headings and structure. Do not add commentary or analysis. URL: ${url}` }
2581
+ ],
2582
+ tools: [{ type: 'url_context' }]
2583
+ });
2584
+ const outputs = result.outputs || [];
2585
+ const textParts = outputs.filter((o) => o.type === 'text').map((o) => o.text);
2586
+ const extractedText = textParts.join('\n');
2587
+ if (!extractedText) {
2588
+ 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.' }) }] };
2589
+ }
2590
+ // ── Query-level billing ──
2591
+ chargeQuery({ queryTypeCode: 'url_as_prompt', apiKey, userId: resolveUserId(userId, uid), query: url, foddaRequest, spt: sptCtx?.token })
2592
+ .catch(e => console.error('[read_url] chargeQuery failed:', e.message));
2593
+ return {
2594
+ content: [{
2595
+ type: 'text',
2596
+ text: JSON.stringify({
2597
+ url,
2598
+ content: extractedText,
2599
+ content_length: extractedText.length,
2600
+ api_calls_charged: 15,
2601
+ hint: 'You now have the full text from this URL. Cross-reference it against Fodda knowledge graphs using search_graph or deep_research_topic to find relevant trends and insights.'
2602
+ }, null, 2)
2603
+ }]
2604
+ };
2605
+ }
2606
+ catch (err) {
2607
+ const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
2608
+ if (trialResult)
2609
+ return trialResult;
2610
+ const msg = err.message || 'URL extraction failed.';
2611
+ console.error('[read_url] Error:', msg);
2612
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
2613
+ }
2614
+ });
2615
+ // --- deep_research_topic (Skills-Based Agent via Waverunner) ---
2616
+ // Uses the fodda-researcher agent with 5 skill instruction files.
2617
+ // Flow: Pre-fetch graph data → Build skill-loaded system instruction →
2618
+ // Call Gemini directly via waverunnerRequest → Stream progress via sendLoggingMessage.
2619
+ 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.', {
2620
+ query: z.string().describe('The research query/topic'),
2621
+ graphId: z.string().optional().describe('Optional specific graph ID to limit the research to'),
2622
+ 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".'),
2623
+ userId: z.string().optional().describe('Optional user identifier.')
2624
+ }, { title: 'Deep Research Topic', readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true }, async ({ query, graphId, depth, userId: uid }) => {
2625
+ const resolvedUserId = resolveUserId(userId, uid);
2626
+ const isHeavy = depth === 'heavy';
2627
+ const tokenCost = isHeavy ? 3 : 2; // Waverunner trial pool tokens
2628
+ const queryTypeCode = isHeavy ? 'deep_research_heavy' : 'deep_research_light';
2629
+ const maxGraphs = isHeavy ? 15 : 8;
2630
+ const startTime = Date.now();
2631
+ // ── SPT pre-run coverage: refuse before kicking off the (long, expensive) job ──
2632
+ const researchGuard = sptGuard(queryTypeCode);
2633
+ if (researchGuard)
2634
+ return researchGuard;
2635
+ try {
2636
+ // Log query to Questions table (fire-and-forget, before cache)
2637
+ logUserQuery(query, 'deep_research');
2638
+ // ── Phase 1: Planning ──
2639
+ await server.sendLoggingMessage({
2640
+ level: 'info',
2641
+ data: `📋 Phase 1/5: Planning research approach for "${query.slice(0, 80)}"...`,
2642
+ });
2643
+ console.error(`[deep_research_topic] Starting ${isHeavy ? 'heavy' : 'light'} research: "${query}"`);
2644
+ // Determine relevant sources — unified routing: graph selection is
2645
+ // getRelevantGraphs() unchanged; earnings/supplemental are appended
2646
+ // as additive extras that never displace graph slots.
2647
+ const sourceCandidates = graphId ? [] : getRelevantSources(query);
2648
+ const graphCandidates = sourceCandidates
2649
+ .filter((c) => c.kind === 'graph')
2650
+ .slice(0, maxGraphs);
2651
+ const graphIds = graphId ? [graphId] : graphCandidates.map(c => c.graphId);
2652
+ const earningsCandidate = sourceCandidates.find((c) => c.kind === 'earnings');
2653
+ const supplementalCandidates = sourceCandidates.filter((c) => c.kind === 'supplemental');
2654
+ // Routing visibility: every candidate selected, with why.
2655
+ // Supplemental is async-only (job + poll) — v1 notes the candidate
2656
+ // instead of fetching, pointing at the targeted tool.
2657
+ const sourcePlan = [
2658
+ ...(graphId
2659
+ ? [{ kind: 'graph', id: graphId, reason: 'explicitly requested via graphId' }]
2660
+ : graphCandidates.map(c => ({ kind: 'graph', id: c.graphId, reason: c.reason }))),
2661
+ ...(earningsCandidate ? [{
2662
+ kind: 'earnings',
2663
+ ...(earningsCandidate.ticker ? { ticker: earningsCandidate.ticker } : {}),
2664
+ ...(earningsCandidate.brand ? { brand: earningsCandidate.brand } : {}),
2665
+ ...(earningsCandidate.sector ? { sector: earningsCandidate.sector } : {}),
2666
+ reason: earningsCandidate.reason,
2667
+ }] : []),
2668
+ ...supplementalCandidates.map(c => ({
2669
+ kind: 'supplemental',
2670
+ category: c.category,
2671
+ reason: `${c.reason} — not auto-fetched in v1 (async source); targeted tool available: get_supplemental_context`,
2672
+ })),
2673
+ ];
2674
+ // ── Phase 2: Searching Fodda Knowledge Graphs ──
2675
+ await server.sendLoggingMessage({
2676
+ level: 'info',
2677
+ data: `🔍 Phase 2/5: Searching ${graphIds.length} knowledge graph${graphIds.length !== 1 ? 's' : ''}${earningsCandidate ? ' + earnings intelligence' : ''}...`,
2678
+ });
2679
+ // Pre-fetch graph data in parallel
2680
+ const graphSearchPromises = graphIds.map(async (gid) => {
2681
+ try {
2682
+ const searchBody = { query, limit: isHeavy ? 10 : 5, use_semantic: true, include_evidence: true };
2683
+ const res = await foddaRequest('POST', `/v1/graphs/${encodeURIComponent(gid)}/search`, apiKey, resolvedUserId, searchBody);
2684
+ const rows = res?.rows || [];
2685
+ const evidence = rows.flatMap((r) => r.evidence || []);
2686
+ return { graphId: gid, rows, evidence };
2687
+ }
2688
+ catch {
2689
+ return { graphId: gid, rows: [], evidence: [] };
2690
+ }
2691
+ });
2692
+ // Earnings candidate: same endpoint as get_earnings_intelligence,
2693
+ // fetched in parallel with graph searches. Failure is non-fatal.
2694
+ const earningsPromise = earningsCandidate
2695
+ ? (async () => {
2696
+ try {
2697
+ const params = new URLSearchParams();
2698
+ if (earningsCandidate.ticker)
2699
+ params.set('ticker', earningsCandidate.ticker);
2700
+ if (earningsCandidate.brand)
2701
+ params.set('brand', earningsCandidate.brand);
2702
+ if (earningsCandidate.sector)
2703
+ params.set('sector', earningsCandidate.sector);
2704
+ if (earningsCandidate.search)
2705
+ params.set('search', earningsCandidate.search);
2706
+ params.set('limit', '10');
2707
+ return await foddaRequest('GET', `/v1/supplemental/earnings/snapshot?${params.toString()}`, apiKey, resolvedUserId);
2708
+ }
2709
+ catch (err) {
2710
+ console.error(`[deep_research_topic] Earnings snapshot failed (non-fatal): ${err?.message}`);
2711
+ return null;
2712
+ }
2713
+ })()
2714
+ : Promise.resolve(null);
2715
+ const [graphResults, earningsData] = await Promise.all([
2716
+ Promise.all(graphSearchPromises),
2717
+ earningsPromise,
2718
+ ]);
2719
+ const totalTrends = graphResults.reduce((sum, g) => sum + g.rows.length, 0);
2720
+ const totalEvidence = graphResults.reduce((sum, g) => sum + g.evidence.length, 0);
2721
+ const activeGraphs = graphResults.filter(g => g.rows.length > 0);
2722
+ // Graceful degradation: if all graph searches failed, the agent
2723
+ // proceeds using ONLY Google Search + URL Context. The report will
2724
+ // be web-only but still useful — no hard failure.
2725
+ if (activeGraphs.length === 0) {
2726
+ console.error(`[deep_research_topic] All ${graphIds.length} graph searches failed — proceeding with web-only research`);
2727
+ await server.sendLoggingMessage({
2728
+ level: 'warning',
2729
+ data: `⚠️ Knowledge graph search returned no results — proceeding with web-only research...`,
2730
+ });
2731
+ }
2732
+ else {
2733
+ await server.sendLoggingMessage({
2734
+ level: 'info',
2735
+ data: `📊 Found ${totalTrends} trend${totalTrends !== 1 ? 's' : ''} and ${totalEvidence} evidence pieces across ${activeGraphs.length} graph${activeGraphs.length !== 1 ? 's' : ''}. Launching deep analysis...`,
2736
+ });
2737
+ }
2738
+ // ── Phase 3: Deep Analysis via Gemini ──
2739
+ await server.sendLoggingMessage({
2740
+ level: 'info',
2741
+ data: `🧠 Phase 3/5: Deep analysis with web research — this takes 1-3 minutes...`,
2742
+ });
2743
+ // Build graph context for the agent
2744
+ const graphContext = {
2745
+ graphResults: JSON.stringify(graphResults.map(g => ({
2746
+ graph_id: g.graphId,
2747
+ trends: g.rows.map((r) => ({
2748
+ name: String(r.title || r.trendName || '').substring(0, 150),
2749
+ summary: String(r.summary || r.description || '').substring(0, 600),
2750
+ signal_score: r.signal_score || r.score,
2751
+ lifecycle: r.trendLifecycle || r.lifecycle,
2752
+ evidence: (r.evidence || []).slice(0, 3).map((e) => ({
2753
+ title: String(e.title || '').substring(0, 150),
2754
+ snippet: String(e.snippet || e.summary || '').substring(0, 400),
2755
+ source_url: e.sourceUrl || e.url,
2756
+ category: e.category || e.type,
2757
+ }))
2758
+ })),
2759
+ // Top-level evidence sample for cross-trend validation
2760
+ evidence: g.evidence.slice(0, isHeavy ? 10 : 5).map((e) => ({
2761
+ title: String(e.title || '').substring(0, 150),
2762
+ snippet: String(e.snippet || e.summary || '').substring(0, 400),
2763
+ source_url: e.sourceUrl || e.url,
2764
+ category: e.category || e.type,
2765
+ brand: e.brandNames?.[0] || e.brand,
2766
+ })),
2767
+ })), null, 2),
2768
+ graphsSearched: activeGraphs.map(g => g.graphId),
2769
+ totalTrends,
2770
+ totalEvidence,
2771
+ focusGraphId: graphId,
2772
+ // Distinct earnings block — attributed by source type at synthesis
2773
+ ...(earningsData ? { earningsResults: JSON.stringify(earningsData).substring(0, 15000) } : {}),
2774
+ };
2775
+ // Build skill-loaded system instruction
2776
+ const systemInstruction = buildResearcherInstruction(query, graphContext);
2777
+ // Call Gemini directly via Waverunner with a timeout guard.
2778
+ // Without a timeout, a stuck Gemini call hangs the MCP tool response
2779
+ // indefinitely — the user sees silence until their client times out.
2780
+ const geminiModel = isHeavy ? 'gemini-2.5-pro' : 'gemini-2.5-flash';
2781
+ // Build the Interactions API payload.
2782
+ // SDK type: BaseCreateModelInteractionParams supports system_instruction
2783
+ // (snake_case) as a top-level parameter alongside model, input, tools.
2784
+ const interactionPayload = {
2785
+ model: geminiModel,
2786
+ system_instruction: systemInstruction,
2787
+ input: [
2788
+ {
2789
+ type: 'text',
2790
+ text: `Research query: ${query}\n\nProduce a comprehensive research report following the skills in your system instruction. Write in editorial narrative style — like a senior strategist briefing a CMO. IMPORTANT: At the end of the report, you MUST include a "## Sources" section listing all the source URLs you used from the provided context.`,
2791
+ },
2792
+ ],
2793
+ tools: [
2794
+ { type: 'google_search' },
2795
+ { type: 'url_context' },
2796
+ ],
2797
+ };
2798
+ const jobId = crypto.randomUUID();
2799
+ activeResearchJobs.set(jobId, { status: 'RUNNING', result: null, error: null });
2800
+ // Run research in the background to avoid Claude Web timeout
2801
+ (async () => {
2802
+ try {
2803
+ let geminiPromise = waverunnerRequest('deep_dive', tokenCost, apiKey, resolvedUserId, interactionPayload);
2804
+ let result;
2805
+ try {
2806
+ result = await geminiPromise;
2807
+ }
2808
+ catch (primaryErr) {
2809
+ const errMsg = primaryErr?.response?.data?.error?.message || primaryErr?.message || '';
2810
+ const isCapacity = errMsg.includes('high demand') || errMsg.includes('overloaded') || errMsg.includes('503');
2811
+ if (isCapacity && geminiModel !== 'gemini-2.5-flash') {
2812
+ console.error(`[deep_research_topic] ${geminiModel} capacity error — retrying with gemini-2.5-flash`);
2813
+ interactionPayload.model = 'gemini-2.5-flash';
2814
+ geminiPromise = waverunnerRequest('deep_dive', tokenCost, apiKey, resolvedUserId, interactionPayload);
2815
+ result = await geminiPromise;
2816
+ }
2817
+ else {
2818
+ throw primaryErr;
2819
+ }
2820
+ }
2821
+ // Extract text from Gemini response
2822
+ const outputs = result?.outputs || [];
2823
+ const textParts = outputs
2824
+ .filter((o) => o.type === 'text')
2825
+ .map((o) => o.text);
2826
+ let reportText = textParts.join('\n\n');
2827
+ // Extract URLs
2828
+ const seenUrls = new Set();
2829
+ const sourceUrls = [];
2830
+ for (const output of outputs) {
2831
+ if (output.type === 'text' && Array.isArray(output.annotations)) {
2832
+ for (const ann of output.annotations) {
2833
+ if (ann.type === 'url_citation' && ann.url && !seenUrls.has(ann.url)) {
2834
+ if (ann.url.includes('vertexaisearch.cloud.google.com'))
2835
+ continue;
2836
+ seenUrls.add(ann.url);
2837
+ sourceUrls.push({ title: ann.title || '', url: ann.url });
2838
+ }
2839
+ }
2840
+ }
2841
+ if (output.type === 'url_context_result' && Array.isArray(output.result)) {
2842
+ for (const ctx of output.result) {
2843
+ if (ctx.url && ctx.status === 'success' && !seenUrls.has(ctx.url)) {
2844
+ seenUrls.add(ctx.url);
2845
+ sourceUrls.push({ title: '', url: ctx.url });
2846
+ }
2847
+ }
2848
+ }
2849
+ }
2850
+ const groundingChunks = result?.groundingMetadata?.groundingChunks || [];
2851
+ for (const chunk of groundingChunks) {
2852
+ if (chunk?.web?.uri && !seenUrls.has(chunk.web.uri)) {
2853
+ seenUrls.add(chunk.web.uri);
2854
+ sourceUrls.push({ title: chunk.web.title || '', url: chunk.web.uri });
2855
+ }
2856
+ }
2857
+ if (sourceUrls.length > 0) {
2858
+ reportText += '\n\n## Sources\n' + sourceUrls.map(s => s.title ? `- [${s.title}](${s.url})` : `- ${s.url}`).join('\n');
2859
+ }
2860
+ if (!reportText) {
2861
+ activeResearchJobs.set(jobId, { status: 'FAILED', error: 'Research agent returned no output.' });
2862
+ return;
2863
+ }
2864
+ const durationSec = ((Date.now() - startTime) / 1000).toFixed(1);
2865
+ // ── Settlement gates delivery for SPT: only mark COMPLETE once the charge succeeds. ──
2866
+ if (sptCtx) {
2867
+ const r = await chargeQuery({ queryTypeCode, apiKey, userId: resolvedUserId, query, graphsSearched: graphIds, foddaRequest, spt: sptCtx.token });
2868
+ if (!r.charged) {
2869
+ activeResearchJobs.set(jobId, { status: 'FAILED', error: r.error || 'Payment could not be completed; report withheld.' });
2870
+ return;
2871
+ }
2872
+ }
2873
+ else {
2874
+ chargeQuery({ queryTypeCode, apiKey, userId: resolvedUserId, query, graphsSearched: graphIds, foddaRequest })
2875
+ .catch(e => console.error('[deep_research_topic] chargeQuery failed:', e.message));
2876
+ }
2877
+ const header = [
2878
+ `_Research by Fodda Research Agent • ${activeGraphs.length} graph${activeGraphs.length !== 1 ? 's' : ''} searched • ${totalTrends} trends analyzed${earningsData ? ' • earnings intelligence included' : ''} • ${durationSec}s_`,
2879
+ '',
2880
+ ].join('\n');
2881
+ activeResearchJobs.set(jobId, { status: 'COMPLETE', result: header + reportText });
2882
+ }
2883
+ catch (err) {
2884
+ const msg = err.response?.data?.error?.message || err.response?.data?.message || err.message;
2885
+ activeResearchJobs.set(jobId, { status: 'FAILED', error: msg });
2886
+ }
2887
+ })();
2888
+ return {
2889
+ content: [{
2890
+ type: 'text',
2891
+ 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.`
2892
+ }]
2893
+ };
2894
+ }
2895
+ catch (err) {
2896
+ const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
2897
+ if (trialResult)
2898
+ return trialResult;
2899
+ const msg = err.message;
2900
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
2901
+ }
2902
+ });
2903
+ // --- check_research_status ---
2904
+ 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.', {
2905
+ job_id: z.string().describe('The Job ID returned by deep_research_topic'),
2906
+ }, { title: 'Check Research Status', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ job_id }) => {
2907
+ const job = activeResearchJobs.get(job_id);
2908
+ if (!job) {
2909
+ return { isError: true, content: [{ type: 'text', text: `Job ID ${job_id} not found. It may have expired or never existed.` }] };
2910
+ }
2911
+ if (job.status === 'RUNNING') {
2912
+ return { content: [{ type: 'text', text: `Job ${job_id} is still RUNNING. The agent is gathering and synthesizing data. Please poll again in 10 seconds.` }] };
2913
+ }
2914
+ if (job.status === 'FAILED') {
2915
+ activeResearchJobs.delete(job_id); // cleanup
2916
+ return { isError: true, content: [{ type: 'text', text: `Job ${job_id} FAILED: ${job.error}` }] };
2917
+ }
2918
+ if (job.status === 'COMPLETE') {
2919
+ activeResearchJobs.delete(job_id); // cleanup
2920
+ return { content: [{ type: 'text', text: job.result }] };
2921
+ }
2922
+ return { isError: true, content: [{ type: 'text', text: `Unknown status for job ${job_id}` }] };
2923
+ });
2924
+ // --- consult_analyst ---
2925
+ 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`.', {
2926
+ analyst_id: z.string().describe("The analyst ID (e.g., 'ben-dietz-sic')"),
2927
+ query: z.string().describe("The question or topic to discuss with the analyst"),
2928
+ 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."),
2929
+ userId: z.string().optional().describe('Optional user identifier.')
2930
+ }, { title: 'Consult Synthetic Analyst', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ analyst_id, query, company, userId: uid }) => {
2931
+ try {
2932
+ // Log query to Questions table (fire-and-forget, before cache)
2933
+ logUserQuery(query, 'consult_analyst');
2934
+ const result = await foddaRequest('POST', `/v1/analysts/consult`, apiKey, resolveUserId(userId, uid), {
2935
+ analyst_id,
2936
+ query,
2937
+ company
2938
+ });
2939
+ // Extract the expert's answer text (legacy-compatible)
2940
+ const reportText = typeof result.result === 'string'
2941
+ ? result.result
2942
+ : (typeof result.report === 'string' ? result.report : JSON.stringify(result, null, 2));
2943
+ const parts = [reportText];
2944
+ // Surface server-side timing for observability
2945
+ if (result.timing_ms != null) {
2946
+ parts.push(`\n--- TIMING: ${result.timing_ms}ms server-side ---`);
2947
+ }
2948
+ // --- Structured envelope fields (Phase 2 Digital Twin) ---
2949
+ if (result.coverage) {
2950
+ parts.push(`\n--- COVERAGE: ${result.coverage} ---`);
2951
+ }
2952
+ if (result.sources_used && Array.isArray(result.sources_used) && result.sources_used.length > 0) {
2953
+ const sourceLines = result.sources_used.map((s) => s.url ? `- ${s.label || s.name || 'Source'}: ${s.url}` : `- ${s.label || s.name || 'Source'}`);
2954
+ parts.push(`--- SOURCES USED ---\n${sourceLines.join('\n')}`);
2955
+ }
2956
+ if (result.referrals && Array.isArray(result.referrals) && result.referrals.length > 0) {
2957
+ const refLines = result.referrals.map((r, i) => `${i + 1}. ${r.name} by ${r.curator || 'unknown'} — ${r.reason || 'related expertise'}`);
2958
+ parts.push(`--- REFERRALS (deliver these in 3rd person as the platform, NOT in the expert's voice) ---\n${refLines.join('\n')}`);
2959
+ }
2960
+ if (result.speaker_note) {
2961
+ parts.push(`--- SPEAKER NOTE: ${result.speaker_note} ---`);
2962
+ }
2963
+ const consultWithheld = await settleOrWithhold({ queryTypeCode: 'expert_agent', apiKey, userId: resolveUserId(userId, uid), query }, 'consult_analyst');
2964
+ if (consultWithheld)
2965
+ return consultWithheld;
2966
+ return { content: [{ type: 'text', text: parts.join('\n') }] };
2967
+ }
2968
+ catch (err) {
2969
+ const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
2970
+ if (trialResult)
2971
+ return trialResult;
2972
+ // Surface timeout explicitly so clients get actionable guidance
2973
+ if (err.code === 'ECONNABORTED' || err.message?.includes('timeout')) {
2974
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({
2975
+ 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.`,
2976
+ analyst_id,
2977
+ timeout: true
2978
+ }) }] };
2979
+ }
2980
+ const msg = err.response?.data?.error?.message || err.response?.data?.message || err.message;
2981
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
2982
+ }
2983
+ });
2984
+ return server;
2985
+ }
2986
+ //# sourceMappingURL=toolHandlers.js.map