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