fodda-mcp 1.32.1 → 1.46.31
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 +14 -16
- package/dist/a2aHandler.d.ts +33 -3
- package/dist/a2aHandler.d.ts.map +1 -1
- package/dist/a2aHandler.js +189 -36
- package/dist/a2aHandler.js.map +1 -1
- package/dist/agentFacts.d.ts +21 -0
- package/dist/agentFacts.d.ts.map +1 -0
- package/dist/agentFacts.js +72 -0
- package/dist/agentFacts.js.map +1 -0
- package/dist/agents/fodda-researcher/index.d.ts +6 -0
- package/dist/agents/fodda-researcher/index.d.ts.map +1 -1
- package/dist/agents/fodda-researcher/index.js +18 -0
- package/dist/agents/fodda-researcher/index.js.map +1 -1
- package/dist/agents/fodda-researcher/skills.d.ts +1 -1
- package/dist/agents/fodda-researcher/skills.d.ts.map +1 -1
- package/dist/agents/fodda-researcher/skills.js +8 -3
- package/dist/agents/fodda-researcher/skills.js.map +1 -1
- package/dist/brandTemplate.d.ts +1 -1
- package/dist/brandTemplate.d.ts.map +1 -1
- package/dist/brandTemplate.js +179 -11
- package/dist/brandTemplate.js.map +1 -1
- package/dist/catalogCache.d.ts +14 -13
- package/dist/catalogCache.d.ts.map +1 -1
- package/dist/catalogCache.js +266 -67
- package/dist/catalogCache.js.map +1 -1
- package/dist/coverageRelevance.d.ts +156 -0
- package/dist/coverageRelevance.d.ts.map +1 -0
- package/dist/coverageRelevance.js +1062 -0
- package/dist/coverageRelevance.js.map +1 -0
- package/dist/deepResearch.d.ts +43 -0
- package/dist/deepResearch.d.ts.map +1 -0
- package/dist/deepResearch.js +498 -0
- package/dist/deepResearch.js.map +1 -0
- package/dist/enrichment.d.ts.map +1 -1
- package/dist/enrichment.js +51 -1
- package/dist/enrichment.js.map +1 -1
- package/dist/errorHandling.d.ts +1 -1
- package/dist/errorHandling.d.ts.map +1 -1
- package/dist/errorHandling.js +26 -6
- package/dist/errorHandling.js.map +1 -1
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +548 -38
- package/dist/index.js.map +1 -1
- package/dist/linkedinEngine.d.ts +150 -0
- package/dist/linkedinEngine.d.ts.map +1 -0
- package/dist/linkedinEngine.js +696 -0
- package/dist/linkedinEngine.js.map +1 -0
- package/dist/oauthRegisterShim.d.ts +13 -0
- package/dist/oauthRegisterShim.d.ts.map +1 -0
- package/dist/oauthRegisterShim.js +83 -0
- package/dist/oauthRegisterShim.js.map +1 -0
- package/dist/pricingCache.d.ts +6 -8
- package/dist/pricingCache.d.ts.map +1 -1
- package/dist/pricingCache.js +69 -13
- package/dist/pricingCache.js.map +1 -1
- package/dist/queryCache.d.ts +2 -0
- package/dist/queryCache.d.ts.map +1 -1
- package/dist/queryCache.js +11 -2
- package/dist/queryCache.js.map +1 -1
- package/dist/resources.d.ts +27 -0
- package/dist/resources.d.ts.map +1 -0
- package/dist/resources.js +126 -0
- package/dist/resources.js.map +1 -0
- package/dist/sessionTracker.d.ts +24 -2
- package/dist/sessionTracker.d.ts.map +1 -1
- package/dist/sessionTracker.js +198 -4
- package/dist/sessionTracker.js.map +1 -1
- package/dist/systemPrompt.d.ts +3 -3
- package/dist/systemPrompt.d.ts.map +1 -1
- package/dist/systemPrompt.js +81 -61
- package/dist/systemPrompt.js.map +1 -1
- package/dist/telemetry.d.ts +43 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +101 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/test_analyst.js +2 -2
- package/dist/test_analyst.js.map +1 -1
- package/dist/test_book_a_call_live.d.ts +2 -0
- package/dist/test_book_a_call_live.d.ts.map +1 -0
- package/dist/test_book_a_call_live.js +148 -0
- package/dist/test_book_a_call_live.js.map +1 -0
- package/dist/test_brand_intelligence_endpoint.d.ts +2 -0
- package/dist/test_brand_intelligence_endpoint.d.ts.map +1 -0
- package/dist/test_brand_intelligence_endpoint.js +20 -0
- package/dist/test_brand_intelligence_endpoint.js.map +1 -0
- package/dist/test_coverage_relevance.d.ts +12 -0
- package/dist/test_coverage_relevance.d.ts.map +1 -0
- package/dist/test_coverage_relevance.js +121 -0
- package/dist/test_coverage_relevance.js.map +1 -0
- package/dist/test_dcr_and_legacy_deprecation.d.ts +2 -0
- package/dist/test_dcr_and_legacy_deprecation.d.ts.map +1 -0
- package/dist/test_dcr_and_legacy_deprecation.js +148 -0
- package/dist/test_dcr_and_legacy_deprecation.js.map +1 -0
- package/dist/test_financial_snapshot_render.d.ts +2 -0
- package/dist/test_financial_snapshot_render.d.ts.map +1 -0
- package/dist/test_financial_snapshot_render.js +167 -0
- package/dist/test_financial_snapshot_render.js.map +1 -0
- package/dist/test_gap_alert.d.ts +12 -0
- package/dist/test_gap_alert.d.ts.map +1 -0
- package/dist/test_gap_alert.js +43 -0
- package/dist/test_gap_alert.js.map +1 -0
- package/dist/test_list_graphs_topics.d.ts +2 -0
- package/dist/test_list_graphs_topics.d.ts.map +1 -0
- package/dist/test_list_graphs_topics.js +26 -0
- package/dist/test_list_graphs_topics.js.map +1 -0
- package/dist/test_live_endpoint_runner.d.ts +2 -0
- package/dist/test_live_endpoint_runner.d.ts.map +1 -0
- package/dist/test_live_endpoint_runner.js +65 -0
- package/dist/test_live_endpoint_runner.js.map +1 -0
- package/dist/test_local_list_graphs.d.ts +2 -0
- package/dist/test_local_list_graphs.d.ts.map +1 -0
- package/dist/test_local_list_graphs.js +53 -0
- package/dist/test_local_list_graphs.js.map +1 -0
- package/dist/test_next_moves.d.ts +2 -0
- package/dist/test_next_moves.d.ts.map +1 -0
- package/dist/test_next_moves.js +349 -0
- package/dist/test_next_moves.js.map +1 -0
- package/dist/test_next_moves_transcripts.d.ts +2 -0
- package/dist/test_next_moves_transcripts.d.ts.map +1 -0
- package/dist/test_next_moves_transcripts.js +413 -0
- package/dist/test_next_moves_transcripts.js.map +1 -0
- package/dist/test_nike_cmo_alias.d.ts +2 -0
- package/dist/test_nike_cmo_alias.d.ts.map +1 -0
- package/dist/test_nike_cmo_alias.js +76 -0
- package/dist/test_nike_cmo_alias.js.map +1 -0
- package/dist/test_session_next_moves_telemetry.d.ts +2 -0
- package/dist/test_session_next_moves_telemetry.d.ts.map +1 -0
- package/dist/test_session_next_moves_telemetry.js +100 -0
- package/dist/test_session_next_moves_telemetry.js.map +1 -0
- package/dist/test_token_resolution.d.ts +2 -0
- package/dist/test_token_resolution.d.ts.map +1 -0
- package/dist/test_token_resolution.js +82 -0
- package/dist/test_token_resolution.js.map +1 -0
- package/dist/toolHandlers.d.ts +7 -3
- package/dist/toolHandlers.d.ts.map +1 -1
- package/dist/toolHandlers.js +1678 -364
- package/dist/toolHandlers.js.map +1 -1
- package/dist/tools.d.ts +7 -2
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +12 -3
- package/dist/tools.js.map +1 -1
- package/dist/types.d.ts +9 -46
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -1
- package/dist/verify_tools_endpoint.js +4 -3
- package/dist/verify_tools_endpoint.js.map +1 -1
- package/package.json +5 -2
- package/server.json +16 -3
- package/dist/scripts/simulate_gemini.d.ts +0 -2
- package/dist/scripts/simulate_gemini.d.ts.map +0 -1
- package/dist/scripts/simulate_gemini.js +0 -100
- package/dist/scripts/simulate_gemini.js.map +0 -1
- package/dist/scripts/verify_registry.d.ts +0 -2
- package/dist/scripts/verify_registry.d.ts.map +0 -1
- package/dist/scripts/verify_registry.js +0 -77
- package/dist/scripts/verify_registry.js.map +0 -1
- package/dist/src/index.d.ts +0 -2
- package/dist/src/index.d.ts.map +0 -1
- package/dist/src/index.js +0 -332
- package/dist/src/index.js.map +0 -1
- package/dist/src/tools.d.ts +0 -12
- package/dist/src/tools.d.ts.map +0 -1
- package/dist/src/tools.js +0 -104
- package/dist/src/tools.js.map +0 -1
- package/dist/src/types.d.ts +0 -44
- package/dist/src/types.d.ts.map +0 -1
- package/dist/src/types.js +0 -2
- package/dist/src/types.js.map +0 -1
- package/dist/src/verify.d.ts +0 -2
- package/dist/src/verify.d.ts.map +0 -1
- package/dist/src/verify.js +0 -47
- package/dist/src/verify.js.map +0 -1
- package/dist/src/verify_hmac.d.ts +0 -2
- package/dist/src/verify_hmac.d.ts.map +0 -1
- package/dist/src/verify_hmac.js +0 -106
- package/dist/src/verify_hmac.js.map +0 -1
- package/dist/src/verify_tools_endpoint.d.ts +0 -2
- package/dist/src/verify_tools_endpoint.d.ts.map +0 -1
- package/dist/src/verify_tools_endpoint.js +0 -40
- package/dist/src/verify_tools_endpoint.js.map +0 -1
- package/dist/test-waverunner-response.d.ts +0 -2
- package/dist/test-waverunner-response.d.ts.map +0 -1
- package/dist/test-waverunner-response.js +0 -20
- package/dist/test-waverunner-response.js.map +0 -1
package/dist/toolHandlers.js
CHANGED
|
@@ -5,12 +5,14 @@
|
|
|
5
5
|
* Contains createServer() which registers all 30+ MCP tools.
|
|
6
6
|
*/
|
|
7
7
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
8
|
-
import { ListResourcesRequestSchema, ListPromptsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
8
|
+
import { ListResourcesRequestSchema, ListPromptsRequestSchema, ListResourceTemplatesRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
9
|
+
import { recordToolOutcome, recordFeedbackEntry } from './telemetry.js';
|
|
10
|
+
import { FODDA_RESOURCE_TEMPLATES, readFoddaResource, listFoddaSampleResources } from './resources.js';
|
|
9
11
|
import { z } from 'zod';
|
|
10
12
|
import { GoogleGenAI } from '@google/genai';
|
|
11
13
|
import axios from 'axios';
|
|
12
14
|
import crypto from 'crypto';
|
|
13
|
-
import { buildDynamicPromptSections, getDomainGraphIds, getGraphs, getLiveGraphs, buildDisplayName, getRelevantGraphs, getRelevantSources, getEnabledSkillConfigs, getSkillGraphs, getAnalysts } from './catalogCache.js';
|
|
15
|
+
import { buildDynamicPromptSections, getDomainGraphIds, getGraphs, getLiveGraphs, buildDisplayName, getRelevantGraphs, getRelevantSources, getEnabledSkillConfigs, getSkillGraphs, getAnalysts, classifyGraphTier } from './catalogCache.js';
|
|
14
16
|
import { renderBrandWidget } from './brandTemplate.js';
|
|
15
17
|
import { renderSearchWidget } from './searchTemplate.js';
|
|
16
18
|
import { FODDA_COMPONENT_GUIDE, getShellTemplate } from './widgetShell.js';
|
|
@@ -22,31 +24,77 @@ import { chargeQuery, getToolCostSummary } from './pricingCache.js';
|
|
|
22
24
|
import { callOutputSkills, buildSkillInput, discoverSkillTools, executeSkillTool, mapSkillError } from './skillClient.js';
|
|
23
25
|
import { createSessionTracker, postToSlack } from './sessionTracker.js';
|
|
24
26
|
import { buildResearcherInstruction } from './agents/fodda-researcher/index.js';
|
|
27
|
+
import { buildEvidencePack, QuotaExhaustedError } from './linkedinEngine.js';
|
|
28
|
+
import { runDeepResearch, cleanResearchQuery, fallbackSubThemes, extractRoutingTopic } from './deepResearch.js';
|
|
29
|
+
import { addCoverageAnnotation, generateNextMoves, generateConsultNextMoves, renderConsultClosingEnvelope, renderClosingBlock, specificQueryTokens } from './coverageRelevance.js';
|
|
25
30
|
// ---------------------------------------------------------------------------
|
|
26
31
|
// Render instructions — embedded in tool responses for LLM clients that
|
|
27
32
|
// don't receive MCP server-level `instructions` (e.g. Claude.ai).
|
|
28
33
|
// ---------------------------------------------------------------------------
|
|
29
|
-
const RENDER_SPEC_VERSION = '1.
|
|
34
|
+
const RENDER_SPEC_VERSION = '1.3';
|
|
35
|
+
export function resolveAnalystAlias(analystIdInput, companyInput) {
|
|
36
|
+
if (!analystIdInput)
|
|
37
|
+
return { analyst_id: analystIdInput, company: companyInput };
|
|
38
|
+
const rawId = analystIdInput.trim();
|
|
39
|
+
const existingCompany = companyInput?.trim();
|
|
40
|
+
const formatCompany = (str) => {
|
|
41
|
+
const s = str.trim();
|
|
42
|
+
if (!s)
|
|
43
|
+
return s;
|
|
44
|
+
if (s === s.toLowerCase()) {
|
|
45
|
+
return s.replace(/\b\w/g, char => char.toUpperCase());
|
|
46
|
+
}
|
|
47
|
+
return s;
|
|
48
|
+
};
|
|
49
|
+
const roleRules = [
|
|
50
|
+
{ roleId: 'brand-cmo', pattern: /\b(brand[-_ ]?cmo|synthetic[-_ ]?cmo|chief[-_ ]marketing[-_ ]officer|vp[-_ ]of[-_ ]marketing|head[-_ ]of[-_ ]marketing|cmo)\b/i },
|
|
51
|
+
{ roleId: 'brand-ceo', pattern: /\b(brand[-_ ]?ceo|synthetic[-_ ]?ceo|chief[-_ ]executive[-_ ]officer|ceo)\b/i },
|
|
52
|
+
{ roleId: 'brand-cfo', pattern: /\b(brand[-_ ]?cfo|synthetic[-_ ]?cfo|chief[-_ ]financial[-_ ]officer|cfo)\b/i },
|
|
53
|
+
{ roleId: 'brand-cro', pattern: /\b(brand[-_ ]?cro|synthetic[-_ ]?cro|chief[-_ ]revenue[-_ ]officer|cro)\b/i },
|
|
54
|
+
{ roleId: 'brand-cto', pattern: /\b(brand[-_ ]?cto|synthetic[-_ ]?cto|chief[-_ ]technology[-_ ]officer|cto)\b/i },
|
|
55
|
+
{ roleId: 'brand-coo', pattern: /\b(brand[-_ ]?coo|synthetic[-_ ]?coo|chief[-_ ]operating[-_ ]officer|coo)\b/i }
|
|
56
|
+
];
|
|
57
|
+
for (const rule of roleRules) {
|
|
58
|
+
if (rule.pattern.test(rawId)) {
|
|
59
|
+
let extractedCompany = existingCompany;
|
|
60
|
+
if (!extractedCompany) {
|
|
61
|
+
const companyPart = rawId
|
|
62
|
+
.replace(rule.pattern, '')
|
|
63
|
+
.replace(/\b(ask|consult|talk|to|query|get|synthetic|expert|analyst|the|brand)\b/gi, '')
|
|
64
|
+
.replace(/[-_]/g, ' ')
|
|
65
|
+
.replace(/\s+/g, ' ')
|
|
66
|
+
.trim();
|
|
67
|
+
if (companyPart) {
|
|
68
|
+
extractedCompany = formatCompany(companyPart);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
analyst_id: rule.roleId,
|
|
73
|
+
company: extractedCompany ? extractedCompany : undefined
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { analyst_id: rawId, company: existingCompany ? existingCompany : undefined };
|
|
78
|
+
}
|
|
30
79
|
function buildRenderInstructions(opts) {
|
|
31
80
|
const rules = [
|
|
32
81
|
'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.',
|
|
82
|
+
'LEAD: Open with one sentence — the sharpest editorial claim the results support. Never open with methodology ("I searched...", "The graphs returned...") or result counts.',
|
|
83
|
+
'ONE TREND, ONE PARAGRAPH: Each trend gets exactly one paragraph of at most 3 sentences (~60 words). Open the paragraph with the trend name in bold followed by its lifecycle stage in italics, e.g. **Human-centric luxury** *(building)*. Insert a blank line between trends — never run two trends into one paragraph.',
|
|
84
|
+
'MAX 3 TRENDS by default, ranked by relevance, even when the payload contains more. Mention in the closing line that further trends are available on request. Exception: the user explicitly asked for an exhaustive list.',
|
|
85
|
+
'CITATIONS — SHORT ANCHORS: Every claim still requires its source link. Prefer short_citation (e.g. "[via Jing Daily](url)") or short source labels ("via Jing Daily", "BoF-McKinsey survey"), never the full evidence headline. Place links at the end of a sentence or in a trailing parenthetical — never mid-clause. Maximum 2 links per trend paragraph; if a trend has more evidence, cite the strongest 2 and note more exists.',
|
|
86
|
+
'NEXT MOVES CLOSING BLOCK (Render Spec 1.3): Every research answer and expert consult must end with a deterministic three-sentence closing block (no heading, no "any questions?", no emoji, no apology) in this fixed order: (1) Pull the thread: One specific thing surfaced but not finished, generated from next_moves.thread. In general search, use natural editorial phrasing ("several more trends/signals" for 2–8, "many more trends/signals" for 10+, or honest thin version). In expert consults (consult_human_agent / consult_analyst), this is the expert\'s authentic 1st-person next move (using expert_thread.next_angle or uncited themes, or referral recommendation on out-of-lane decline). (2) Explore the shelf / Go specific: Merchandises <=2 relevant graphs from catalogCache (excluding expert\'s own graph) or offers brand/statistics options from next_moves.specific. (3) Scope to the job: Fixed copy: "If you tell me the brand or brief you\'re working on, I\'ll cut this to that." (or "Want this cut to [brand] specifically?" if the user\'s research profile specifies a brand). Never invent names, brands, or numbers — all material must come from next_moves or result rows.',
|
|
87
|
+
'NO FILLER STRUCTURE: No section headers for responses of 3 trends or fewer. No bullet-lists of evidence items. No restating evidence counts, graph counts, or relevance scores in prose.',
|
|
88
|
+
'DEPTH ON REQUEST: When the user asks about ONE specific trend, the density caps lift — expand to full evidence, longer paragraphs, and complete citations. The caps govern first-pass survey responses, not drill-downs.',
|
|
89
|
+
'COVERAGE RECOVERY: coverage.status is machine-readable routing data — NEVER tell the user coverage is "thin", "low", or "empty". When status is thin or empty, silently run coverage.suggested_action (get_supplemental_context) in the SAME turn and blend what it returns into the answer. If that also returns nothing usable, present what exists plainly — "this is what we have on this right now" — and offer the coverage.escalation options (a Deep Dive report via deep_research_topic, or your own web/LLM research with non-Fodda findings clearly attributed). Never pad weak matches into a confident-looking answer.',
|
|
33
90
|
];
|
|
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
91
|
if (opts.hasWidget) {
|
|
42
92
|
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
93
|
}
|
|
44
|
-
rules.push('Keep responses clean and structured. Lead with a sharp editorial claim, not a summary of data methodology.');
|
|
45
94
|
const instructions = {
|
|
46
95
|
_render_spec_version: RENDER_SPEC_VERSION,
|
|
47
96
|
rules,
|
|
48
97
|
};
|
|
49
|
-
// Graph-level link templates (populated when catalog has webpage_url)
|
|
50
98
|
if (opts.graphWebpageUrls && Object.keys(opts.graphWebpageUrls).length > 0) {
|
|
51
99
|
instructions._source_links = opts.graphWebpageUrls;
|
|
52
100
|
}
|
|
@@ -123,6 +171,7 @@ const GRAPH_LIST_ALLOWLIST = new Set([
|
|
|
123
171
|
'graph_id', 'name', 'one_liner', 'description', 'curator',
|
|
124
172
|
'domain', 'graph_type', 'trend_count', 'evidence_count',
|
|
125
173
|
'status', 'last_updated',
|
|
174
|
+
'topics', 'verticals',
|
|
126
175
|
]);
|
|
127
176
|
const SNAKE_TO_CAMEL = {
|
|
128
177
|
'graph_id': 'graphId', 'one_liner': 'oneLiner', 'graph_type': 'graphType',
|
|
@@ -171,7 +220,7 @@ const activeResearchJobs = new Map();
|
|
|
171
220
|
const activeSupplementalJobs = new Map();
|
|
172
221
|
export async function createServer(apiKey, userId, foddaRequest, waverunnerRequest, storeWidget, getServiceUrl, entryId = '',
|
|
173
222
|
// anonymous SPT session: token (settlement payer) + connect-time cap/prices (pre-run coverage)
|
|
174
|
-
sptCtx) {
|
|
223
|
+
sptCtx, allowedTools) {
|
|
175
224
|
// ── SPT settlement helpers (inert for credit/API-key sessions: sptCtx is undefined) ──
|
|
176
225
|
// Pre-run guard: refuse a task BEFORE spending compute if this payment token can't cover it.
|
|
177
226
|
// Returns an error result to return immediately, or null to proceed.
|
|
@@ -262,24 +311,78 @@ sptCtx) {
|
|
|
262
311
|
// (rejected as no-longer-valid, or surfaced reactively via errorHandling.ts).
|
|
263
312
|
// No client-side trial state remains.
|
|
264
313
|
const sessionTracker = createSessionTracker();
|
|
314
|
+
const getKnownBrand = () => {
|
|
315
|
+
if (accountProfile?.companyName && accountProfile.companyName.trim()) {
|
|
316
|
+
return accountProfile.companyName.trim();
|
|
317
|
+
}
|
|
318
|
+
if (accountProfile?.userContext) {
|
|
319
|
+
// Match explicit brand: or client: keys only
|
|
320
|
+
const m = accountProfile.userContext.match(/\b(?:brand|client):\s*["']?([A-Za-z0-9&' -]{2,40}?)["']?(?:[.,;\n\r]|$)/i);
|
|
321
|
+
if (m && m[1]) {
|
|
322
|
+
const brand = m[1].trim();
|
|
323
|
+
if (!/^(?:the\s+next|next\s+|this\s+|our\s+|a\s+|an\s+|none|n\/a|unknown)/i.test(brand)) {
|
|
324
|
+
return brand;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return undefined;
|
|
329
|
+
};
|
|
265
330
|
// Fire-and-forget: log the user's query text to the Questions table.
|
|
266
331
|
// Called at tool entry — BEFORE cache-eligible foddaRequest calls —
|
|
267
332
|
// so the question is captured even when the MCP query cache serves a hit.
|
|
268
|
-
function logUserQuery(query, interactionType, graphId) {
|
|
333
|
+
function logUserQuery(query, interactionType, graphId, toolArgs) {
|
|
334
|
+
const nextMoveTaken = sessionTracker.evaluateNextMoveMatch(query, interactionType, { graphId, ...toolArgs });
|
|
269
335
|
foddaRequest('POST', '/v1/log/question', apiKey, userId, {
|
|
270
336
|
question: query,
|
|
271
337
|
graphId: graphId || 'all',
|
|
272
338
|
interactionType,
|
|
273
339
|
source: 'mcp',
|
|
340
|
+
...(nextMoveTaken ? { next_move_taken: nextMoveTaken, nextMoveTaken } : {}),
|
|
341
|
+
}).catch(() => { }); // Never block on logging failures
|
|
342
|
+
}
|
|
343
|
+
// Fire-and-forget: log post-search aggregate quality and graph attribution to the Questions table.
|
|
344
|
+
// Called after search resolution so aggregate coverage is captured and enriches the entry log.
|
|
345
|
+
function logQueryResult(query, interactionType, coverage, searchedGraphs, nextMoves) {
|
|
346
|
+
if (nextMoves) {
|
|
347
|
+
sessionTracker.recordNextMoves(nextMoves, query);
|
|
348
|
+
}
|
|
349
|
+
if (!coverage || coverage.status === 'error')
|
|
350
|
+
return;
|
|
351
|
+
const count = coverage.results_on_topic !== undefined
|
|
352
|
+
? coverage.results_on_topic
|
|
353
|
+
: (coverage.results_returned ?? 0);
|
|
354
|
+
const resultQuality = (coverage.status === 'empty' || count === 0)
|
|
355
|
+
? 'MISS'
|
|
356
|
+
: count >= 5
|
|
357
|
+
? 'STRONG'
|
|
358
|
+
: 'WEAK';
|
|
359
|
+
const graphIds = [...new Set((searchedGraphs || []).map((g) => typeof g === 'string' ? g : (g.graph_id || g.id || g.name)).filter(Boolean))];
|
|
360
|
+
const userContext = graphIds.length > 0 ? `searched_graphs: ${graphIds.join(', ')}` : undefined;
|
|
361
|
+
foddaRequest('POST', '/v1/log/question', apiKey, userId, {
|
|
362
|
+
question: query.trim(),
|
|
363
|
+
interactionType,
|
|
364
|
+
source: 'mcp',
|
|
365
|
+
resultCount: count,
|
|
366
|
+
resultQuality,
|
|
367
|
+
userContext,
|
|
274
368
|
}).catch(() => { }); // Never block on logging failures
|
|
275
369
|
}
|
|
276
|
-
// Build skill metadata for system prompt — includes both output-phase and interactive skills
|
|
370
|
+
// Build skill metadata for system prompt — includes both output-phase and interactive skills.
|
|
371
|
+
// Mirrors the router-only collapse below: when a skill has a router tool,
|
|
372
|
+
// the prompt advertises ONLY the router (advertising unregistered tools
|
|
373
|
+
// would make the model call names that don't exist).
|
|
277
374
|
const skillPromptMeta = sessionSkills.map(s => {
|
|
278
375
|
const discovered = discoveredSkills.find(d => d.skill_id === s.id);
|
|
376
|
+
const advertised = (() => {
|
|
377
|
+
if (!discovered)
|
|
378
|
+
return [];
|
|
379
|
+
const routerTool = discovered.tools.find(t => /router/i.test(t.name));
|
|
380
|
+
return (routerTool ? [routerTool] : discovered.tools).map(t => `${s.id}_${t.name}`);
|
|
381
|
+
})();
|
|
279
382
|
return {
|
|
280
383
|
id: s.id,
|
|
281
384
|
name: s.name,
|
|
282
|
-
interactiveTools:
|
|
385
|
+
interactiveTools: advertised,
|
|
283
386
|
costPerCall: discovered?.cost_per_call ?? 2,
|
|
284
387
|
};
|
|
285
388
|
});
|
|
@@ -289,27 +392,79 @@ sptCtx) {
|
|
|
289
392
|
}, {
|
|
290
393
|
instructions: buildSystemPrompt(accountProfile, skillPromptMeta, entryId),
|
|
291
394
|
});
|
|
292
|
-
// Register
|
|
395
|
+
// Register capabilities and citable fodda:// resource handlers
|
|
293
396
|
server.server.registerCapabilities({
|
|
294
|
-
resources: {
|
|
397
|
+
resources: {
|
|
398
|
+
subscribe: false,
|
|
399
|
+
listChanged: false,
|
|
400
|
+
},
|
|
295
401
|
prompts: {}
|
|
296
402
|
});
|
|
297
403
|
server.server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
|
298
|
-
return { resources:
|
|
404
|
+
return { resources: listFoddaSampleResources() };
|
|
405
|
+
});
|
|
406
|
+
server.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
|
|
407
|
+
return {
|
|
408
|
+
resourceTemplates: FODDA_RESOURCE_TEMPLATES.map(t => ({
|
|
409
|
+
uriTemplate: t.uriTemplate,
|
|
410
|
+
name: t.name,
|
|
411
|
+
description: t.description,
|
|
412
|
+
mimeType: t.mimeType,
|
|
413
|
+
}))
|
|
414
|
+
};
|
|
415
|
+
});
|
|
416
|
+
server.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
417
|
+
const uri = request.params?.uri;
|
|
418
|
+
const res = await readFoddaResource(uri, apiKey, userId, foddaRequest);
|
|
419
|
+
return {
|
|
420
|
+
contents: [{
|
|
421
|
+
uri: res.uri,
|
|
422
|
+
mimeType: res.mimeType,
|
|
423
|
+
text: res.text
|
|
424
|
+
}]
|
|
425
|
+
};
|
|
299
426
|
});
|
|
300
427
|
server.server.setRequestHandler(ListPromptsRequestSchema, async () => {
|
|
301
428
|
return { prompts: [] };
|
|
302
429
|
});
|
|
303
430
|
// ── Register discovered interactive skill tools as MCP tools ──
|
|
431
|
+
// Router-only collapse (Piers, 2026-07-05): external skills that expose a
|
|
432
|
+
// router/entry-point tool register ONLY that tool — the router dispatches
|
|
433
|
+
// to the skill's other techniques internally. Without this, one skill can
|
|
434
|
+
// fan out into a dozen+ tools (Paralogy published 16), bloating every
|
|
435
|
+
// session's tool list with a third party's internals. Skills with no
|
|
436
|
+
// router register all their tools as before.
|
|
304
437
|
for (const discovered of discoveredSkills) {
|
|
305
|
-
|
|
438
|
+
// Skill-level directory_visible filter — opt-out from directory build
|
|
439
|
+
if (discovered.directory_visible === false)
|
|
440
|
+
continue;
|
|
441
|
+
const routerTool = discovered.tools.find(t => /router/i.test(t.name));
|
|
442
|
+
const toolsToRegister = routerTool ? [routerTool] : discovered.tools;
|
|
443
|
+
if (routerTool && discovered.tools.length > 1) {
|
|
444
|
+
console.error(`[skills] ${discovered.skill_id}: collapsed ${discovered.tools.length} tools to router-only (${routerTool.name})`);
|
|
445
|
+
}
|
|
446
|
+
for (const tool of toolsToRegister) {
|
|
447
|
+
// Tool-level directory_visible filter
|
|
448
|
+
if (tool.directory_visible === false)
|
|
449
|
+
continue;
|
|
306
450
|
const prefixedName = `${discovered.skill_id}_${tool.name}`;
|
|
307
451
|
const costNote = `(costs ${discovered.cost_per_call} API calls)`;
|
|
308
452
|
const description = `[${discovered.skill_name}] ${tool.description || tool.name} ${costNote}`;
|
|
453
|
+
// Build per-tool annotations from skills API, with safe defaults.
|
|
454
|
+
// IMPORTANT: readOnlyHint defaults to false (write-capable) when not provided by the API —
|
|
455
|
+
// the fail-safe direction is to assume writes may occur, not assume read-only.
|
|
456
|
+
const toolAnnotations = tool.annotations;
|
|
457
|
+
const annotations = {
|
|
458
|
+
title: `${discovered.skill_name}: ${tool.name}`,
|
|
459
|
+
readOnlyHint: toolAnnotations?.readOnlyHint ?? false,
|
|
460
|
+
destructiveHint: toolAnnotations?.destructiveHint ?? false,
|
|
461
|
+
idempotentHint: toolAnnotations?.idempotentHint ?? false,
|
|
462
|
+
openWorldHint: toolAnnotations?.openWorldHint ?? true,
|
|
463
|
+
};
|
|
309
464
|
// Build a Zod schema from the tool's inputSchema
|
|
310
465
|
// The inputSchema from the API is a JSON Schema object — we accept
|
|
311
466
|
// 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.') },
|
|
467
|
+
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.') }, annotations, async ({ arguments: toolArgs }) => {
|
|
313
468
|
try {
|
|
314
469
|
const { output, durationMs } = await executeSkillTool(discovered.skill_id, tool.name, toolArgs || {}, apiKey, userId);
|
|
315
470
|
return {
|
|
@@ -413,12 +568,29 @@ sptCtx) {
|
|
|
413
568
|
};
|
|
414
569
|
}
|
|
415
570
|
catch (err) {
|
|
416
|
-
const
|
|
571
|
+
const status = err.response?.status;
|
|
572
|
+
const errData = err.response?.data?.error || err.response?.data || {};
|
|
573
|
+
if (status === 402 || errData.code === 'CREDITS_EXHAUSTED' || errData.status === 'CREDITS_EXHAUSTED') {
|
|
574
|
+
return {
|
|
575
|
+
content: [{
|
|
576
|
+
type: 'text',
|
|
577
|
+
text: JSON.stringify({
|
|
578
|
+
status: 'CREDITS_EXHAUSTED',
|
|
579
|
+
api_calls_remaining: 0,
|
|
580
|
+
note: 'Your account has 0 API calls remaining for this billing cycle.',
|
|
581
|
+
manage_url: 'https://app.fodda.ai/account',
|
|
582
|
+
upgrade_url: 'https://app.fodda.ai/portal?action=upgrade',
|
|
583
|
+
query_costs: getToolCostSummary(),
|
|
584
|
+
}, null, 2)
|
|
585
|
+
}]
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
const msg = errData.message || err.message;
|
|
417
589
|
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
|
|
418
590
|
}
|
|
419
591
|
});
|
|
420
592
|
// --- 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.
|
|
593
|
+
server.tool('list_graphs', 'List all expert knowledge graphs the user can access — IDs, descriptions, authors, sectors, signal counts, and topic coverage (e.g. retail, tech, food, travel, fashion, beauty, sports). Use FIRST in any session to discover available sources before searching. Returns graph metadata needed for graphId parameters in other tools.', { 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
594
|
try {
|
|
423
595
|
const data = await foddaRequest('GET', '/v1/graphs', apiKey, resolveUserId(userId, uid));
|
|
424
596
|
// P0: Apply allowlist serializer — strips PII (owner_email) and CMS bloat fields,
|
|
@@ -454,11 +626,203 @@ sptCtx) {
|
|
|
454
626
|
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
|
|
455
627
|
}
|
|
456
628
|
});
|
|
629
|
+
// --- get_capabilities ---
|
|
630
|
+
server.tool('get_capabilities', 'Returns Fodda\'s main capabilities / features / offerings / products / services / tools and what they cost. Call this for any question about what Fodda can do or what\'s available.', { userId: z.string().optional().describe('Optional user identifier.') }, { title: 'Get Fodda Capabilities & Pricing', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async () => {
|
|
631
|
+
const toolCosts = getToolCostSummary();
|
|
632
|
+
const getCostStr = (toolName, fallback) => {
|
|
633
|
+
const matches = toolCosts.filter(c => c.tool === toolName);
|
|
634
|
+
if (matches.length > 0) {
|
|
635
|
+
const costs = matches.map(m => m.apiCalls);
|
|
636
|
+
const min = Math.min(...costs);
|
|
637
|
+
const max = Math.max(...costs);
|
|
638
|
+
return min === max ? `${min} API calls` : `${min}–${max} API calls`;
|
|
639
|
+
}
|
|
640
|
+
return fallback;
|
|
641
|
+
};
|
|
642
|
+
return {
|
|
643
|
+
content: [{
|
|
644
|
+
type: 'text',
|
|
645
|
+
text: JSON.stringify({
|
|
646
|
+
scope_rule: "Unqualified questions about offerings, features, capabilities, products, services, or tools refer to these platform capabilities. Analyst-specific offerings apply ONLY when an analyst is explicitly named in the question.",
|
|
647
|
+
capabilities: [
|
|
648
|
+
{
|
|
649
|
+
id: 'brand_intelligence',
|
|
650
|
+
name: 'Brand Intelligence',
|
|
651
|
+
value: 'Brand health, trend footprint & competitive landscape for any brand.',
|
|
652
|
+
tools: ['brand_tracker'],
|
|
653
|
+
cost_units: getCostStr('brand_tracker', '20 API calls'),
|
|
654
|
+
audience: 'Brand strategists, market researchers, competitive intelligence teams',
|
|
655
|
+
example_prompts: [
|
|
656
|
+
'Run a brand intelligence footprint for Patagonia focusing on circular economy signals.',
|
|
657
|
+
'Audit Nike\'s competitive landscape across expert graphs.'
|
|
658
|
+
]
|
|
659
|
+
},
|
|
660
|
+
{
|
|
661
|
+
id: 'deep_research',
|
|
662
|
+
name: 'Deep Research',
|
|
663
|
+
value: 'Autonomous multi-graph research briefing report with multi-source synthesis.',
|
|
664
|
+
tools: ['deep_research_topic'],
|
|
665
|
+
cost_units: getCostStr('deep_research_topic', '20–30 API calls'),
|
|
666
|
+
audience: 'Strategists needing exhaustive, executive-ready briefing decks',
|
|
667
|
+
example_prompts: [
|
|
668
|
+
'Write a comprehensive briefing on how Gen Z is reshaping luxury retail in APAC.'
|
|
669
|
+
]
|
|
670
|
+
},
|
|
671
|
+
{
|
|
672
|
+
id: 'earnings_intelligence',
|
|
673
|
+
name: 'Earnings Intelligence',
|
|
674
|
+
value: 'Earnings-call analysis, divergence & per-ticker canonical records.',
|
|
675
|
+
tools: ['get_company_earnings', 'get_earnings_intelligence', 'get_earnings_divergence', 'get_validated_trends'],
|
|
676
|
+
cost_units: '0–15 API calls (coverage is free)',
|
|
677
|
+
audience: 'Financial analysts, equity researchers, corporate strategy',
|
|
678
|
+
example_prompts: [
|
|
679
|
+
'What are retail executives saying about inventory levels?',
|
|
680
|
+
'Show analyst-management divergence for hotel companies in Q1.'
|
|
681
|
+
]
|
|
682
|
+
},
|
|
683
|
+
{
|
|
684
|
+
id: 'topic_research',
|
|
685
|
+
name: 'Topic Research',
|
|
686
|
+
value: 'Multi-graph topic search + evidence + stats across expert knowledge graphs.',
|
|
687
|
+
tools: ['search_graph', 'search_statistics', 'search_insights'],
|
|
688
|
+
cost_units: getCostStr('search_graph', '15 API calls'),
|
|
689
|
+
audience: 'Researchers, planners, innovation teams',
|
|
690
|
+
example_prompts: [
|
|
691
|
+
'Pressure-test our sustainability strategy against Fodda\'s packaging trends.',
|
|
692
|
+
'Search statistics for resale market growth rates.'
|
|
693
|
+
]
|
|
694
|
+
},
|
|
695
|
+
{
|
|
696
|
+
id: 'expert_consult',
|
|
697
|
+
name: 'Expert Consult & Deliverables',
|
|
698
|
+
value: 'Direct chat with named synthetic experts & commissioned finished deliverables.',
|
|
699
|
+
tools: ['consult_analyst', 'list_analysts', 'request_deliverable'],
|
|
700
|
+
cost_units: '5–10 API calls',
|
|
701
|
+
audience: 'Teams seeking specialized domain perspectives or marketing plans',
|
|
702
|
+
example_prompts: [
|
|
703
|
+
'Consult Ben Dietz to pressure-test our luxury fashion tech roadmap.',
|
|
704
|
+
'List available synthetic experts and their domain focus.'
|
|
705
|
+
]
|
|
706
|
+
}
|
|
707
|
+
],
|
|
708
|
+
additional_services: [
|
|
709
|
+
{
|
|
710
|
+
name: 'Scheduled Intelligence Briefings',
|
|
711
|
+
tool: 'manage_scheduled_reports',
|
|
712
|
+
cost_units: getCostStr('manage_scheduled_reports', '20 API calls'),
|
|
713
|
+
description: 'Track brand positioning or topic trends on a weekly automated schedule.'
|
|
714
|
+
},
|
|
715
|
+
{
|
|
716
|
+
name: 'Executive Content Studio',
|
|
717
|
+
tools: ['draft_linkedin_post', 'draft_linkedin_article'],
|
|
718
|
+
cost_units: '10 API calls',
|
|
719
|
+
description: 'Draft evidence-backed executive articles and posts from Fodda graph data.'
|
|
720
|
+
}
|
|
721
|
+
]
|
|
722
|
+
}, null, 2)
|
|
723
|
+
}]
|
|
724
|
+
};
|
|
725
|
+
});
|
|
726
|
+
let cachedHumanAgentsBookACall = new Map();
|
|
727
|
+
let lastHumanAgentsFetchTime = 0;
|
|
728
|
+
const HUMAN_AGENTS_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
729
|
+
async function getHumanAgentsBookACallMap(foddaReq, apiKey, userId) {
|
|
730
|
+
const now = Date.now();
|
|
731
|
+
if (cachedHumanAgentsBookACall.size > 0 && (now - lastHumanAgentsFetchTime) < HUMAN_AGENTS_CACHE_TTL_MS) {
|
|
732
|
+
return cachedHumanAgentsBookACall;
|
|
733
|
+
}
|
|
734
|
+
try {
|
|
735
|
+
const haData = await foddaReq('GET', '/v1/human-agents', apiKey, userId);
|
|
736
|
+
const list = Array.isArray(haData) ? haData : (haData?.human_agents || haData?.analysts || haData?.data || []);
|
|
737
|
+
const newMap = new Map();
|
|
738
|
+
for (const a of list) {
|
|
739
|
+
const key = (a.analyst_id || a.id || a.slug || a.name || '').toLowerCase().trim();
|
|
740
|
+
if (key) {
|
|
741
|
+
newMap.set(key, a.book_a_call ?? null);
|
|
742
|
+
}
|
|
743
|
+
if (a.name) {
|
|
744
|
+
newMap.set(a.name.toLowerCase().trim(), a.book_a_call ?? null);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
cachedHumanAgentsBookACall = newMap;
|
|
748
|
+
lastHumanAgentsFetchTime = now;
|
|
749
|
+
return cachedHumanAgentsBookACall;
|
|
750
|
+
}
|
|
751
|
+
catch {
|
|
752
|
+
return cachedHumanAgentsBookACall;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
457
755
|
// --- list_analysts ---
|
|
458
|
-
server.tool('list_analysts', '
|
|
756
|
+
server.tool('list_analysts', 'Lists available human agents and synthetic analysts (e.g. brand-cmo, brand-ceo, brand-cfo, human experts like Anu Lingala). To query a company-specific synthetic expert (e.g., "Nike CMO", "Apple CMO", "Adidas CEO"), consult brand-cmo (or relevant role ID) and supply the target company name in the company parameter (e.g. company: "Nike").', { userId: z.string().optional().describe('Optional user identifier.') }, { title: 'List Human Agents & Synthetic Analysts', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ userId: uid }) => {
|
|
459
757
|
try {
|
|
460
|
-
const
|
|
461
|
-
|
|
758
|
+
const targetUserId = resolveUserId(userId, uid);
|
|
759
|
+
const [data, bookACallMap] = await Promise.all([
|
|
760
|
+
foddaRequest('GET', '/v1/analysts', apiKey, targetUserId),
|
|
761
|
+
getHumanAgentsBookACallMap(foddaRequest, apiKey, targetUserId)
|
|
762
|
+
]);
|
|
763
|
+
let analystsList = [];
|
|
764
|
+
if (Array.isArray(data)) {
|
|
765
|
+
analystsList = data;
|
|
766
|
+
}
|
|
767
|
+
else if (data && typeof data === 'object') {
|
|
768
|
+
analystsList = data.analysts || data.rows || data.data || [];
|
|
769
|
+
}
|
|
770
|
+
const company_query_guide = "To consult a company-specific executive (e.g., 'Nike CMO', 'Target CFO'), call consult_analyst with analyst_id='brand-cmo' and company='Nike'.";
|
|
771
|
+
if (Array.isArray(analystsList) && analystsList.length > 0) {
|
|
772
|
+
const seen = new Map();
|
|
773
|
+
for (const a of analystsList) {
|
|
774
|
+
const key = (a.analyst_id || a.id || a.slug || a.name || '').toLowerCase().trim();
|
|
775
|
+
if (!key)
|
|
776
|
+
continue;
|
|
777
|
+
const isHumanAgent = a.type === 'human_agent' || a.type === 'human_twin' || a.agent_type === 'human_twin' || a.agent_type === 'human_agent' || a.kind === 'human_agent' || a.kind === 'human_twin' || a.is_digital_twin === true || a.is_human_agent === true;
|
|
778
|
+
const type = isHumanAgent ? 'human_agent' : (a.type || 'synthetic_analyst');
|
|
779
|
+
const consult_tool = isHumanAgent ? 'consult_human_agent' : 'consult_analyst';
|
|
780
|
+
const price = a.price || a.promoPriceUsd || a.publishedPriceUsd || a.price_usd || undefined;
|
|
781
|
+
const hasOfferings = Array.isArray(a.offerings) && a.offerings.length > 0;
|
|
782
|
+
const nameKey = (a.name || '').toLowerCase().trim();
|
|
783
|
+
const book_a_call = a.book_a_call !== undefined ? a.book_a_call : (bookACallMap.get(key) ?? (nameKey ? bookACallMap.get(nameKey) : null) ?? (isHumanAgent ? null : undefined));
|
|
784
|
+
const credentials = (a.roleTitle || a.yearsExperience != null || a.pastEmployers || a.role_title || a.years_experience != null || a.past_employers) ? {
|
|
785
|
+
roleTitle: a.roleTitle || a.role_title || undefined,
|
|
786
|
+
yearsExperience: a.yearsExperience ?? a.years_experience ?? undefined,
|
|
787
|
+
pastEmployers: a.pastEmployers || a.past_employers || undefined
|
|
788
|
+
} : undefined;
|
|
789
|
+
const enriched = {
|
|
790
|
+
analyst_id: a.analyst_id || a.id || a.slug || a.name || '',
|
|
791
|
+
name: a.name,
|
|
792
|
+
type,
|
|
793
|
+
consult_tool,
|
|
794
|
+
...(a.expertIn || a.expert_in || a.topic ? { expert_in: a.expertIn || a.expert_in || a.topic } : {}),
|
|
795
|
+
...(a.description ? { description: a.description } : {}),
|
|
796
|
+
...(a.askLine || a.what_they_offer || a.whatTheyOffer ? { what_they_offer: a.askLine || a.what_they_offer || a.whatTheyOffer } : {}),
|
|
797
|
+
...(a.exampleQueries || a.example_questions ? { example_questions: a.exampleQueries || a.example_questions } : {}),
|
|
798
|
+
...(a.blindSpots || a.outside_their_lane || a.outsideTheirLane ? { outside_their_lane: a.blindSpots || a.outside_their_lane || a.outsideTheirLane } : {}),
|
|
799
|
+
...(credentials ? { credentials } : {}),
|
|
800
|
+
is_verified_real_person: Boolean(a.isVerifiedRealPerson || a.is_verified_real_person || isHumanAgent),
|
|
801
|
+
...(price !== undefined ? { price } : {}),
|
|
802
|
+
...(book_a_call !== undefined ? { book_a_call } : {}),
|
|
803
|
+
offerings: a.offerings || [],
|
|
804
|
+
commissionable: hasOfferings,
|
|
805
|
+
...(!hasOfferings ? { note: `Analyst profile available for consultation (${consult_tool}); deliverables not yet commissionable.` } : {})
|
|
806
|
+
};
|
|
807
|
+
if (!seen.has(key)) {
|
|
808
|
+
seen.set(key, enriched);
|
|
809
|
+
}
|
|
810
|
+
else {
|
|
811
|
+
const existing = seen.get(key);
|
|
812
|
+
if (!existing.commissionable && hasOfferings) {
|
|
813
|
+
seen.set(key, enriched);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
const deduplicated = Array.from(seen.values());
|
|
818
|
+
const result = {
|
|
819
|
+
company_query_guide,
|
|
820
|
+
analysts: deduplicated
|
|
821
|
+
};
|
|
822
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
823
|
+
}
|
|
824
|
+
const fallbackResult = { company_query_guide, analysts: [] };
|
|
825
|
+
return { content: [{ type: 'text', text: JSON.stringify(fallbackResult, null, 2) }] };
|
|
462
826
|
}
|
|
463
827
|
catch (err) {
|
|
464
828
|
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
@@ -469,15 +833,17 @@ sptCtx) {
|
|
|
469
833
|
}
|
|
470
834
|
});
|
|
471
835
|
// --- 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.', {
|
|
836
|
+
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. Price: $20 per query.', {
|
|
837
|
+
mode: z.enum(['research', 'compare']).optional().default('research').describe('Execution mode: "research" for topic research, "compare" for upload & compare intelligence. Defaults to "research".'),
|
|
838
|
+
graphs: z.array(z.string()).optional().describe("Optional explicit graph scope: an array of graph IDs. When provided, the search is restricted to EXACTLY these graphs — no fallback routing to other graphs. Graph IDs that are unknown, not live, or not yet synced are reported back in `unavailable_graphs` with a reason. Takes precedence over graphId."),
|
|
473
839
|
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.
|
|
840
|
+
query: z.string().describe('The search query. Country/regional terms filter results at the macro level. Note: Knowledge graph trends are indexed at country/global scope — for sub-national or city-level data (e.g., "US coastal cities"), also query get_supplemental_context.'),
|
|
475
841
|
userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
|
|
476
842
|
limit: z.number().optional().describe('Maximum number of results (default 10, max 50)'),
|
|
477
843
|
use_semantic: z.boolean().optional().describe('Whether to use semantic search (default true)'),
|
|
478
844
|
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
|
|
480
|
-
}, { title: 'Search Knowledge Graph', readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint:
|
|
845
|
+
skip_skills: z.boolean().optional().describe('If true, skip applying any enabled search enhancement skills for this query only. Use when you want raw, un-enhanced graph results. Default: false.')
|
|
846
|
+
}, { title: 'Search Knowledge Graph', readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true }, async ({ mode, graphs, graphId, query, userId: uid, limit, use_semantic, include_evidence, skip_skills }) => {
|
|
481
847
|
try {
|
|
482
848
|
// Log query to Questions table (fire-and-forget, before cache)
|
|
483
849
|
logUserQuery(query, 'search', graphId);
|
|
@@ -490,11 +856,56 @@ sptCtx) {
|
|
|
490
856
|
};
|
|
491
857
|
// ── Supplemental data is deferred until we know results are relevant ──
|
|
492
858
|
let data;
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
859
|
+
let searchedGraphs = [];
|
|
860
|
+
// ── Explicit graph scope (`graphs` array): strict, no fallback routing ──
|
|
861
|
+
const explicitScope = Array.isArray(graphs) && graphs.length > 0
|
|
862
|
+
? [...new Set(graphs.map(g => String(g).trim()).filter(Boolean))]
|
|
863
|
+
: null;
|
|
864
|
+
let unavailableGraphs = [];
|
|
865
|
+
let scopedGraphs = null;
|
|
866
|
+
if (explicitScope) {
|
|
867
|
+
const catalog = getGraphs();
|
|
868
|
+
const available = [];
|
|
869
|
+
for (const gid of explicitScope) {
|
|
870
|
+
const g = catalog.find(x => x.graph_id === gid);
|
|
871
|
+
if (!g) {
|
|
872
|
+
unavailableGraphs.push({ graph_id: gid, reason: 'unknown graph id (not in catalog)' });
|
|
873
|
+
}
|
|
874
|
+
else if (g.status !== 'live') {
|
|
875
|
+
unavailableGraphs.push({ graph_id: gid, reason: `not live (status: ${g.status})` });
|
|
876
|
+
}
|
|
877
|
+
else if (!(g.trend_count > 0) && !g.last_synced) {
|
|
878
|
+
unavailableGraphs.push({ graph_id: gid, reason: 'not synced (no embedded content yet)' });
|
|
879
|
+
}
|
|
880
|
+
else {
|
|
881
|
+
available.push(g);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
if (available.length === 0) {
|
|
885
|
+
// Never substitute other graphs under an explicit scope.
|
|
886
|
+
return {
|
|
887
|
+
content: [{
|
|
888
|
+
type: 'text', text: JSON.stringify({
|
|
889
|
+
rows: [],
|
|
890
|
+
total: 0,
|
|
891
|
+
dataStatus: 'SCOPE_UNAVAILABLE',
|
|
892
|
+
unavailable_graphs: unavailableGraphs,
|
|
893
|
+
message: 'None of the requested graphs are searchable. No other graphs were substituted. Call list_graphs for available graph ids.',
|
|
894
|
+
}, null, 2)
|
|
895
|
+
}]
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
scopedGraphs = available;
|
|
899
|
+
}
|
|
900
|
+
// If explicit scope, or no graphId / deprecated 'psfk', use fan-out
|
|
901
|
+
if (scopedGraphs || !graphId || graphId === 'psfk') {
|
|
902
|
+
// Step 1: explicit scope searches exactly those graphs; otherwise
|
|
903
|
+
// score query against graph metadata to find relevant graphs
|
|
904
|
+
const relevantGraphs = scopedGraphs
|
|
905
|
+
? scopedGraphs.map(g => ({ graph: g, score: 1.0, graphTier: classifyGraphTier(g), isDirectMatch: true }))
|
|
906
|
+
: getRelevantGraphs(query);
|
|
497
907
|
const graphsToSearch = relevantGraphs.map(r => r.graph);
|
|
908
|
+
searchedGraphs = graphsToSearch;
|
|
498
909
|
const perGraphLimit = Math.max(5, Math.ceil(effectiveLimit / Math.max(graphsToSearch.length, 1)));
|
|
499
910
|
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
911
|
// Merge rows, deduplicate by trendId + near-duplicate name detection
|
|
@@ -523,8 +934,11 @@ sptCtx) {
|
|
|
523
934
|
continue;
|
|
524
935
|
}
|
|
525
936
|
const fulfilled = r;
|
|
526
|
-
const
|
|
937
|
+
const val = fulfilled.value;
|
|
938
|
+
const rows = Array.isArray(val) ? val : (val?.rows || []);
|
|
527
939
|
const graphMeta = relevantGraphs[i];
|
|
940
|
+
const graphTotal = val?.total ?? (Array.isArray(val) ? val.length : (val?.rows?.length || 0));
|
|
941
|
+
const graphOnTopicTotal = val?.on_topic_total ?? val?.total_count ?? graphTotal;
|
|
528
942
|
for (const row of rows) {
|
|
529
943
|
const key = row.trendId || row.node_id || row.trendName || `${row.name}_${row.signal_score}`;
|
|
530
944
|
if (seen.has(String(key)))
|
|
@@ -536,6 +950,9 @@ sptCtx) {
|
|
|
536
950
|
seen.add(String(key));
|
|
537
951
|
if (rowName)
|
|
538
952
|
seenNames.push(rowName);
|
|
953
|
+
// Attach per-graph totals from API envelope so each graph's remainder can be computed
|
|
954
|
+
row.on_topic_total = graphOnTopicTotal;
|
|
955
|
+
row.total = graphTotal;
|
|
539
956
|
// Tag with source tier and label for editorial composition
|
|
540
957
|
if (graphMeta) {
|
|
541
958
|
const g = graphMeta.graph;
|
|
@@ -649,11 +1066,19 @@ sptCtx) {
|
|
|
649
1066
|
const trialResult = await handleTrialCreditExhaustion(creditRejection, apiKey, userId);
|
|
650
1067
|
if (trialResult)
|
|
651
1068
|
return trialResult;
|
|
652
|
-
return await handleAccessError(creditRejection, 'search_graph');
|
|
1069
|
+
return await handleAccessError(creditRejection, 'search_graph', userId, apiKey);
|
|
653
1070
|
}
|
|
654
|
-
|
|
1071
|
+
const fanoutTotal = results.filter(r => r.status === 'fulfilled').reduce((sum, r) => sum + (r.value?.total || r.value?.rows?.length || (Array.isArray(r.value) ? r.value.length : 0)), 0);
|
|
1072
|
+
const fanoutOnTopicTotal = results.filter(r => r.status === 'fulfilled').reduce((sum, r) => sum + (r.value?.on_topic_total || r.value?.total_count || r.value?.total || r.value?.rows?.length || (Array.isArray(r.value) ? r.value.length : 0)), 0);
|
|
1073
|
+
data = { rows: finalRows, dataStatus: allRows.length > 0 ? 'ok' : 'NO_MATCH', _routed_graphs: actualSourceGraphs, total: fanoutTotal, on_topic_total: fanoutOnTopicTotal };
|
|
1074
|
+
if (unavailableGraphs.length > 0)
|
|
1075
|
+
data.unavailable_graphs = unavailableGraphs;
|
|
655
1076
|
}
|
|
656
1077
|
else {
|
|
1078
|
+
const matchedGraph = getGraphs().find(g => g.graph_id === graphId);
|
|
1079
|
+
if (matchedGraph) {
|
|
1080
|
+
searchedGraphs = [matchedGraph];
|
|
1081
|
+
}
|
|
657
1082
|
data = await foddaRequest('POST', `/v1/graphs/${encodeURIComponent(graphId)}/search`, apiKey, resolveUserId(userId, uid), body);
|
|
658
1083
|
}
|
|
659
1084
|
// ── Track search for frustration detection ──
|
|
@@ -798,7 +1223,7 @@ sptCtx) {
|
|
|
798
1223
|
data.geoBias = { concentrated: true, region: [...uniqueRegions][0], note: 'Results are geographically concentrated' };
|
|
799
1224
|
}
|
|
800
1225
|
if (enrichedRows.length < 3 || enrichedRows.every((r) => (r.evidence_count || r.evidenceCount || 0) < 3)) {
|
|
801
|
-
data.research_gaps = { thin_coverage: true, note: '
|
|
1226
|
+
data.research_gaps = { thin_coverage: true, note: 'Closest available matches. Machine-only flag: recover via get_supplemental_context before answering; if that also comes up short, present these as what exists today and offer a Deep Dive report (deep_research_topic) or a web research pass. Never describe coverage as limited in user-facing prose.' };
|
|
802
1227
|
}
|
|
803
1228
|
// Confidence-gated fallback: auto-broaden thin results
|
|
804
1229
|
if (enrichedRows.length < 3 && query.split(' ').length > 3 && effectiveGraphId) {
|
|
@@ -828,6 +1253,16 @@ sptCtx) {
|
|
|
828
1253
|
}
|
|
829
1254
|
catch { /* Broadening failed silently */ }
|
|
830
1255
|
}
|
|
1256
|
+
data = addCoverageAnnotation(data, query, searchedGraphs, limit, false, getGraphs(), {
|
|
1257
|
+
total: data.total,
|
|
1258
|
+
onTopicTotal: data.on_topic_total,
|
|
1259
|
+
knownBrand: getKnownBrand(),
|
|
1260
|
+
});
|
|
1261
|
+
sessionTracker.postGapToSlack(resolveUserId(userId, uid), 'search_graph', query, data?.coverage);
|
|
1262
|
+
logQueryResult(query, 'search', data?.coverage, searchedGraphs, data?.next_moves);
|
|
1263
|
+
if (data?.coverage?.status === 'error' || data?.error) {
|
|
1264
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1265
|
+
}
|
|
831
1266
|
// ── Low-credit warning for all users — utilizes dynamic Stripe links from API ──
|
|
832
1267
|
appendUsageWarning(data, resolveUserId(userId));
|
|
833
1268
|
// ── Supplemental data — macro context for all queries with results ──
|
|
@@ -987,7 +1422,7 @@ sptCtx) {
|
|
|
987
1422
|
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
988
1423
|
if (trialResult)
|
|
989
1424
|
return trialResult;
|
|
990
|
-
return await handleAccessError(err, 'search_graph');
|
|
1425
|
+
return await handleAccessError(err, 'search_graph', userId, apiKey);
|
|
991
1426
|
}
|
|
992
1427
|
});
|
|
993
1428
|
// --- get_neighbors ---
|
|
@@ -1023,7 +1458,7 @@ sptCtx) {
|
|
|
1023
1458
|
}
|
|
1024
1459
|
});
|
|
1025
1460
|
// --- 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.', {
|
|
1461
|
+
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. Price: $0.50 per lookup.', {
|
|
1027
1462
|
graphId: z.string().describe(GRAPH_ID_DESC),
|
|
1028
1463
|
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
1464
|
userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
|
|
@@ -1120,11 +1555,25 @@ sptCtx) {
|
|
|
1120
1555
|
if (include_editorial !== undefined)
|
|
1121
1556
|
params.set('include_editorial', String(include_editorial));
|
|
1122
1557
|
let data = await foddaRequest('GET', `/v1/graphs/${encodeURIComponent(graphId)}/adjacent?${params.toString()}`, apiKey, resolveUserId(userId, uid));
|
|
1558
|
+
data = addCoverageAnnotation(data, trend_id, [graphId], limit, true, getGraphs(), {
|
|
1559
|
+
knownBrand: getKnownBrand(),
|
|
1560
|
+
});
|
|
1561
|
+
sessionTracker.recordNextMoves(data?.next_moves, trend_id);
|
|
1123
1562
|
appendUsageWarning(data, resolveUserId(userId));
|
|
1124
1563
|
const adjacentWithheld = await settleOrWithhold({ queryTypeCode: 'adjacent_trends', apiKey, userId: resolveUserId(userId, uid), query: trend_id }, 'discover_adjacent_trends');
|
|
1125
1564
|
if (adjacentWithheld)
|
|
1126
1565
|
return adjacentWithheld;
|
|
1127
|
-
|
|
1566
|
+
const adjacentClosing = renderClosingBlock(data?.next_moves);
|
|
1567
|
+
const closingInstruction = adjacentClosing.text
|
|
1568
|
+
? `── NEXT MOVES CLOSING BLOCK (Render Spec 1.3) ──\nReproduce this exact 3-sentence closing block verbatim at the end of your answer (no heading, no "any questions?", no emoji, no apology):\n\n${adjacentClosing.lines.join('\n')}`
|
|
1569
|
+
: '';
|
|
1570
|
+
const content = [
|
|
1571
|
+
{ type: 'text', text: '── RAW DATA (for follow-up reasoning) ──\n' + JSON.stringify(data, null, 2) },
|
|
1572
|
+
];
|
|
1573
|
+
if (closingInstruction) {
|
|
1574
|
+
content.push({ type: 'text', text: closingInstruction });
|
|
1575
|
+
}
|
|
1576
|
+
return { next_moves: data?.next_moves, content };
|
|
1128
1577
|
}
|
|
1129
1578
|
catch (err) {
|
|
1130
1579
|
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
@@ -1159,6 +1608,7 @@ sptCtx) {
|
|
|
1159
1608
|
const competitorCounts = {};
|
|
1160
1609
|
const competitorGraphs = {}; // track which graphs each competitor appears in
|
|
1161
1610
|
let usedCypherEndpoint = false;
|
|
1611
|
+
let marketData = null;
|
|
1162
1612
|
// ── Strategy 1: Single Cypher endpoint (fast path) ──
|
|
1163
1613
|
try {
|
|
1164
1614
|
const params = new URLSearchParams();
|
|
@@ -1167,6 +1617,9 @@ sptCtx) {
|
|
|
1167
1617
|
const cypherData = await foddaRequest('POST', `/v1/brand-intelligence/${encodeURIComponent(brandName)}?${params.toString()}`, apiKey, resolveUserId(userId, uid));
|
|
1168
1618
|
if (cypherData?.ok && cypherData.trend_footprint) {
|
|
1169
1619
|
usedCypherEndpoint = true;
|
|
1620
|
+
if (cypherData.market_data) {
|
|
1621
|
+
marketData = cypherData.market_data;
|
|
1622
|
+
}
|
|
1170
1623
|
// Map Cypher response → MCP profile shape
|
|
1171
1624
|
for (const t of cypherData.trend_footprint) {
|
|
1172
1625
|
const catalogEntry = graphLookup.get(t.graphId);
|
|
@@ -1649,20 +2102,28 @@ sptCtx) {
|
|
|
1649
2102
|
const earningsSnapshot = earningsRaw?.snapshot || earningsRaw; // unwrap .snapshot if present
|
|
1650
2103
|
const earningsItems = earningsSnapshot?.results || earningsSnapshot?.earnings || (Array.isArray(earningsSnapshot) ? earningsSnapshot : []);
|
|
1651
2104
|
profile.earningsIntelligence = earningsItems.length > 0 ? earningsItems : undefined;
|
|
2105
|
+
// Pass through truth-layer fields for brandTemplate.ts rendering
|
|
2106
|
+
profile.earningsSource = earningsRaw?.earningsSource || (earningsSnapshot?.source === 'truth_layer' ? 'truth_layer' : undefined);
|
|
2107
|
+
profile.earningsTruthLayer = earningsRaw?.earningsTruthLayer || earningsSnapshot?.truth_layer || undefined;
|
|
2108
|
+
profile.validatedTrends = earningsRaw?.validatedTrends || earningsSnapshot?.validated_trends || undefined;
|
|
2109
|
+
profile.analystQA = earningsRaw?.analystQA || undefined;
|
|
2110
|
+
if (marketData) {
|
|
2111
|
+
profile.market_data = marketData;
|
|
2112
|
+
}
|
|
1652
2113
|
const widget = await renderBrandWidget(profile);
|
|
1653
2114
|
const EDITORIAL_INSTRUCTION = widget.open_slots.length === 0
|
|
1654
|
-
?
|
|
2115
|
+
? null
|
|
1655
2116
|
: `── 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
2117
|
storeWidget(widget.widget_html);
|
|
1657
2118
|
return { profile, widget, EDITORIAL_INSTRUCTION };
|
|
1658
2119
|
};
|
|
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.', {
|
|
2120
|
+
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. Price: $30 per report.', {
|
|
1660
2121
|
brand_name: z.string().describe("The brand name to look up (e.g. 'Nike', 'Adidas', 'Apple'). Case-insensitive."),
|
|
1661
2122
|
userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
|
|
1662
2123
|
graph_ids: z.array(z.string()).optional().describe('Optional: specific graph IDs to search. If omitted, searches ALL accessible graphs.'),
|
|
1663
2124
|
include_evidence: z.boolean().optional().describe('If true (default), include individual evidence items. Set to false for summary-only.'),
|
|
1664
2125
|
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:
|
|
2126
|
+
}, { title: 'Brand Intelligence Profile', readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true }, async ({ brand_name, userId: uid, graph_ids, include_evidence, max_evidence }) => {
|
|
1666
2127
|
try {
|
|
1667
2128
|
// Log query to Questions table (fire-and-forget, before cache)
|
|
1668
2129
|
logUserQuery(brand_name, 'brand_tracker');
|
|
@@ -1670,15 +2131,130 @@ sptCtx) {
|
|
|
1670
2131
|
const guard = sptGuard('brand_intelligence');
|
|
1671
2132
|
if (guard)
|
|
1672
2133
|
return guard;
|
|
1673
|
-
const { widget, EDITORIAL_INSTRUCTION } = await executeBrandTracker(brand_name, uid, graph_ids, include_evidence, max_evidence);
|
|
2134
|
+
const { profile, widget, EDITORIAL_INSTRUCTION } = await executeBrandTracker(brand_name, uid, graph_ids, include_evidence, max_evidence);
|
|
1674
2135
|
// ── Query-level billing (settlement gates delivery for SPT) ──
|
|
1675
2136
|
const withheld = await settleOrWithhold({ queryTypeCode: 'brand_intelligence', apiKey, userId: resolveUserId(userId, uid), query: brand_name }, 'brand_tracker');
|
|
1676
2137
|
if (withheld)
|
|
1677
2138
|
return withheld;
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
2139
|
+
const brandLower = brand_name.toLowerCase();
|
|
2140
|
+
const footprintTrends = (profile?.trend_footprint || []).slice(0, 15);
|
|
2141
|
+
const footprintGraphCounts = {};
|
|
2142
|
+
for (const t of (profile?.trend_footprint || [])) {
|
|
2143
|
+
const gid = t.graphId || t._use_this_graphId || t.graph_id;
|
|
2144
|
+
if (gid)
|
|
2145
|
+
footprintGraphCounts[gid] = (footprintGraphCounts[gid] || 0) + 1;
|
|
2146
|
+
}
|
|
2147
|
+
const primaryFootprintGraphId = Object.entries(footprintGraphCounts).sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
2148
|
+
const PLATFORM_BLOCKLIST = new Set([
|
|
2149
|
+
'Meituan', 'Taobao', 'Alibaba', 'JD.com', 'Tmall', 'Pinduoduo', 'Shopee',
|
|
2150
|
+
'Amazon', 'eBay', 'Etsy', 'Shopify', 'Walmart', 'Target',
|
|
2151
|
+
'Google', 'Apple', 'Meta', 'Microsoft', 'OpenAI',
|
|
2152
|
+
'Instagram', 'TikTok', 'YouTube', 'Snapchat', 'Pinterest', 'X', 'Twitter', 'Reddit', 'Substack',
|
|
2153
|
+
'Spotify', 'Netflix', 'Disney+', 'Hulu',
|
|
2154
|
+
'Uber', 'Lyft', 'DoorDash', 'Instacart',
|
|
2155
|
+
'WeChat', 'WhatsApp', 'Telegram', 'LINE',
|
|
2156
|
+
'Stripe', 'PayPal', 'Square', 'Klarna',
|
|
2157
|
+
]);
|
|
2158
|
+
const footprintBrandCounts = {};
|
|
2159
|
+
const footprintBrandGraphs = {};
|
|
2160
|
+
for (const t of footprintTrends) {
|
|
2161
|
+
const gid = t.graphId || t._use_this_graphId || t.graph_id;
|
|
2162
|
+
const bList = [];
|
|
2163
|
+
if (Array.isArray(t.brandNames))
|
|
2164
|
+
bList.push(...t.brandNames);
|
|
2165
|
+
else if (typeof t.brandNames === 'string')
|
|
2166
|
+
bList.push(...t.brandNames.split('|').map((s) => s.trim()).filter(Boolean));
|
|
2167
|
+
if (Array.isArray(t.brands))
|
|
2168
|
+
bList.push(...t.brands);
|
|
2169
|
+
if (Array.isArray(t.evidence)) {
|
|
2170
|
+
for (const ev of t.evidence) {
|
|
2171
|
+
if (Array.isArray(ev.brandNames))
|
|
2172
|
+
bList.push(...ev.brandNames);
|
|
2173
|
+
else if (typeof ev.brandNames === 'string')
|
|
2174
|
+
bList.push(...ev.brandNames.split('|').map((s) => s.trim()).filter(Boolean));
|
|
2175
|
+
if (Array.isArray(ev.brands_mentioned))
|
|
2176
|
+
bList.push(...ev.brands_mentioned);
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
for (const b of bList) {
|
|
2180
|
+
if (!b || typeof b !== 'string')
|
|
2181
|
+
continue;
|
|
2182
|
+
const bClean = b.trim();
|
|
2183
|
+
const bLower = bClean.toLowerCase();
|
|
2184
|
+
if (bClean.length <= 2 || bLower === brandLower || brandLower.includes(bLower) || bLower.includes(brandLower))
|
|
2185
|
+
continue;
|
|
2186
|
+
if (PLATFORM_BLOCKLIST.has(bClean))
|
|
2187
|
+
continue;
|
|
2188
|
+
footprintBrandCounts[bClean] = (footprintBrandCounts[bClean] || 0) + 1;
|
|
2189
|
+
if (!footprintBrandGraphs[bClean])
|
|
2190
|
+
footprintBrandGraphs[bClean] = new Set();
|
|
2191
|
+
if (gid)
|
|
2192
|
+
footprintBrandGraphs[bClean].add(gid);
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
// Filter to candidates whose primary graph matches the tracked brand's primary footprint graph
|
|
2196
|
+
const competitiveLandscape = Object.entries(footprintBrandCounts)
|
|
2197
|
+
.filter(([b]) => {
|
|
2198
|
+
const graphs = footprintBrandGraphs[b];
|
|
2199
|
+
return primaryFootprintGraphId ? graphs?.has(primaryFootprintGraphId) : true;
|
|
2200
|
+
})
|
|
2201
|
+
.sort((a, b) => b[1] - a[1])
|
|
2202
|
+
.map(([b]) => b)
|
|
2203
|
+
.slice(0, 2);
|
|
2204
|
+
const pAny = profile;
|
|
2205
|
+
const hasEarnings = !!(pAny?.market_data?.ticker ||
|
|
2206
|
+
pAny?.market_data?.quarterly_financials ||
|
|
2207
|
+
pAny?.earningsTruthLayer ||
|
|
2208
|
+
(pAny?.earningsIntelligence && pAny.earningsIntelligence.length > 0));
|
|
2209
|
+
const brandDisplayName = pAny?.market_data?.company_name || brand_name;
|
|
2210
|
+
const cleanBrandName = brandDisplayName.trim();
|
|
2211
|
+
let possessiveBrandName = `${cleanBrandName}'s`;
|
|
2212
|
+
if (cleanBrandName.endsWith("'s") || cleanBrandName.endsWith("’s")) {
|
|
2213
|
+
possessiveBrandName = cleanBrandName;
|
|
2214
|
+
}
|
|
2215
|
+
else if (cleanBrandName.endsWith("s") || cleanBrandName.endsWith("S")) {
|
|
2216
|
+
possessiveBrandName = `${cleanBrandName}'`;
|
|
2217
|
+
}
|
|
2218
|
+
const earningsStatsSource = hasEarnings
|
|
2219
|
+
? `${possessiveBrandName} latest earnings and financial results`
|
|
2220
|
+
: undefined;
|
|
2221
|
+
const trendCount = profile?.trend_footprint?.length || 0;
|
|
2222
|
+
const coverageStatus = trendCount === 0 ? 'empty' : trendCount < 3 ? 'thin' : 'ok';
|
|
2223
|
+
const brandNextMoves = generateNextMoves(profile?.trend_footprint || [], brand_name, graph_ids || [], coverageStatus, undefined, undefined, getGraphs(), getAnalysts(), {
|
|
2224
|
+
knownBrand: getKnownBrand(),
|
|
2225
|
+
isBrandTracker: true,
|
|
2226
|
+
competitiveLandscape,
|
|
2227
|
+
earningsStatsSource,
|
|
2228
|
+
});
|
|
2229
|
+
sessionTracker.recordNextMoves(brandNextMoves, brand_name);
|
|
2230
|
+
profile.next_moves = brandNextMoves;
|
|
2231
|
+
const brandClosing = renderClosingBlock(brandNextMoves);
|
|
2232
|
+
const closingBlockInstruction = brandClosing.text
|
|
2233
|
+
? `── NEXT MOVES CLOSING BLOCK (Render Spec 1.3) ──\nReproduce this exact 3-sentence closing block verbatim at the end of your answer (no heading, no "any questions?", no emoji, no apology):\n\n${brandClosing.lines.join('\n')}`
|
|
2234
|
+
: '';
|
|
2235
|
+
const rawDataBlock = {
|
|
2236
|
+
type: 'text',
|
|
2237
|
+
text: '── RAW DATA (for follow-up reasoning) ──\n' + JSON.stringify(profile, null, 2),
|
|
2238
|
+
};
|
|
2239
|
+
const widgetBlock = {
|
|
2240
|
+
type: 'text',
|
|
2241
|
+
text: widget.widget_html,
|
|
2242
|
+
};
|
|
2243
|
+
const content = [rawDataBlock];
|
|
2244
|
+
if (EDITORIAL_INSTRUCTION) {
|
|
2245
|
+
const finalEditorial = closingBlockInstruction
|
|
2246
|
+
? `${EDITORIAL_INSTRUCTION}\n\n${closingBlockInstruction}\n`
|
|
2247
|
+
: EDITORIAL_INSTRUCTION;
|
|
2248
|
+
content.push({ type: 'text', text: finalEditorial });
|
|
2249
|
+
content.push(widgetBlock);
|
|
2250
|
+
}
|
|
2251
|
+
else {
|
|
2252
|
+
content.push(widgetBlock);
|
|
2253
|
+
if (closingBlockInstruction) {
|
|
2254
|
+
content.push({ type: 'text', text: closingBlockInstruction });
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
return { next_moves: brandNextMoves, content };
|
|
1682
2258
|
}
|
|
1683
2259
|
catch (err) {
|
|
1684
2260
|
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
@@ -1692,19 +2268,22 @@ sptCtx) {
|
|
|
1692
2268
|
// Replaces 21 individual supplemental tools with a single call.
|
|
1693
2269
|
// The API routes to 6-10 relevant sources based on query + domain,
|
|
1694
2270
|
// queries them in parallel, and returns a consolidated response.
|
|
1695
|
-
server.tool('get_supplemental_context', '
|
|
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'.
|
|
2271
|
+
server.tool('get_supplemental_context', 'A standard layer for macro, institutional, and real-time market data. Call this tool when curated coverage is thin, empty, or when the query is explicitly demand/attention-shaped (e.g. to get search volume, economic series, or census data). It retrieves data from 80+ authoritative sources (Google Trends, FRED, BLS, Census, etc.) fanned out in parallel. Returns categorized data blocks with source attribution and metadata. Note: call after search_graph indicates thin/empty coverage via its coverage annotation. Price: $10 per query.', {
|
|
2272
|
+
query: z.string().describe("The topic or query to get supplemental data for (e.g., 'sustainable packaging', 'tequila spirits market', 'Gen Z beauty'). Include country names if searching non-US markets (e.g. 'Thailand consumer sentiment')."),
|
|
2273
|
+
domain: z.string().optional().describe("Domain hint to improve source routing: 'retail', 'beauty', 'fashion', 'sports', 'food', 'technology', 'culture', 'travel', 'design', 'macro'. Do NOT pass 'culture' or 'technology' for macro economic or consumer sentiment queries — leave omitted or set to 'macro'."),
|
|
2274
|
+
geo: z.string().optional().describe("Country code or geography hint (e.g., 'TH', 'US', 'GB') for country-filtered queries."),
|
|
1698
2275
|
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
2276
|
graph_ids: z.array(z.string()).optional().describe("Graph IDs from prior search results — helps refine domain inference."),
|
|
1700
2277
|
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:
|
|
2278
|
+
}, { title: 'Get Market Context Data', readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true }, async ({ query, domain, geo, brands, graph_ids, userId: uid }) => {
|
|
1702
2279
|
try {
|
|
1703
2280
|
// Log query to Questions table (fire-and-forget, before cache)
|
|
1704
2281
|
logUserQuery(query, 'supplemental_context');
|
|
1705
2282
|
const body = { query };
|
|
1706
2283
|
if (domain)
|
|
1707
2284
|
body.domain = domain;
|
|
2285
|
+
if (geo)
|
|
2286
|
+
body.geo = geo;
|
|
1708
2287
|
if (brands?.length)
|
|
1709
2288
|
body.brands = brands;
|
|
1710
2289
|
if (graph_ids?.length)
|
|
@@ -1718,7 +2297,11 @@ sptCtx) {
|
|
|
1718
2297
|
// ── Query-level billing ──
|
|
1719
2298
|
chargeQuery({ queryTypeCode: 'standalone_supplemental', apiKey, userId: resolveUserId(userId, uid), query, foddaRequest, spt: sptCtx?.token })
|
|
1720
2299
|
.catch(e => console.error('[supplemental] chargeQuery failed:', e.message));
|
|
1721
|
-
|
|
2300
|
+
const nextMoves = generateNextMoves([], query, graph_ids || [], 'ok', undefined, undefined, getGraphs(), getAnalysts(), { knownBrand: getKnownBrand() });
|
|
2301
|
+
if (data && typeof data === 'object') {
|
|
2302
|
+
data.next_moves = nextMoves;
|
|
2303
|
+
}
|
|
2304
|
+
activeSupplementalJobs.set(jobId, { status: 'COMPLETE', result: JSON.stringify(data, null, 2), nextMoves, query });
|
|
1722
2305
|
}
|
|
1723
2306
|
catch (err) {
|
|
1724
2307
|
const errMsg = err.response?.data?.error?.message || err.response?.data?.message || err.message || 'Unknown error';
|
|
@@ -1736,7 +2319,7 @@ sptCtx) {
|
|
|
1736
2319
|
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
1737
2320
|
if (trialResult)
|
|
1738
2321
|
return trialResult;
|
|
1739
|
-
return await handleAccessError(err, 'supplemental');
|
|
2322
|
+
return await handleAccessError(err, 'supplemental', userId, apiKey);
|
|
1740
2323
|
}
|
|
1741
2324
|
});
|
|
1742
2325
|
// --- check_supplemental_status ---
|
|
@@ -1750,6 +2333,13 @@ sptCtx) {
|
|
|
1750
2333
|
if (job.status === 'RUNNING') {
|
|
1751
2334
|
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
2335
|
}
|
|
2336
|
+
if (job.status === 'COMPLETE') {
|
|
2337
|
+
activeSupplementalJobs.delete(job_id); // cleanup
|
|
2338
|
+
if (job.nextMoves) {
|
|
2339
|
+
sessionTracker.recordNextMoves(job.nextMoves, job.query || '');
|
|
2340
|
+
}
|
|
2341
|
+
return { content: [{ type: 'text', text: job.result }] };
|
|
2342
|
+
}
|
|
1753
2343
|
if (job.status === 'FAILED') {
|
|
1754
2344
|
activeSupplementalJobs.delete(job_id); // cleanup
|
|
1755
2345
|
return { isError: true, content: [{ type: 'text', text: `Job ${job_id} FAILED: ${job.error}` }] };
|
|
@@ -1762,7 +2352,7 @@ sptCtx) {
|
|
|
1762
2352
|
});
|
|
1763
2353
|
// --- get_domain_intelligence ---
|
|
1764
2354
|
// 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.", {
|
|
2355
|
+
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. Note: Graph trends represent country-level and global signals; for city-level or regional sub-cuts, use get_supplemental_context.", {
|
|
1766
2356
|
query: z.string().describe("Natural language search query (e.g., 'sustainable packaging trends', 'Gen Z beauty habits')"),
|
|
1767
2357
|
limit: z.number().optional().describe('Max trends to return (default: 10, max: 50)'),
|
|
1768
2358
|
include_evidence: z.boolean().optional().describe('Bundle evidence for each trend (default: true)'),
|
|
@@ -1786,13 +2376,24 @@ sptCtx) {
|
|
|
1786
2376
|
const domainWithheld = await settleOrWithhold({ queryTypeCode: 'domain_intelligence', apiKey, userId: resolveUserId(userId, uid), query }, 'get_domain_intelligence');
|
|
1787
2377
|
if (domainWithheld)
|
|
1788
2378
|
return domainWithheld;
|
|
1789
|
-
|
|
2379
|
+
const searchedGraphs = getLiveGraphs().filter(g => g.graph_type === 'domain');
|
|
2380
|
+
const annotatedData = addCoverageAnnotation(data, query, searchedGraphs, limit, false, getGraphs(), {
|
|
2381
|
+
total: data?.total,
|
|
2382
|
+
onTopicTotal: data?.on_topic_total,
|
|
2383
|
+
knownBrand: getKnownBrand(),
|
|
2384
|
+
});
|
|
2385
|
+
sessionTracker.postGapToSlack(resolveUserId(userId, uid), 'get_domain_intelligence', query, annotatedData?.coverage);
|
|
2386
|
+
logQueryResult(query, 'domain_intelligence', annotatedData?.coverage, searchedGraphs, annotatedData?.next_moves);
|
|
2387
|
+
if (annotatedData?.coverage?.status === 'error' || annotatedData?.error) {
|
|
2388
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify(annotatedData, null, 2) }] };
|
|
2389
|
+
}
|
|
2390
|
+
return { content: [{ type: 'text', text: JSON.stringify(annotatedData, null, 2) }] };
|
|
1790
2391
|
}
|
|
1791
2392
|
catch (err) {
|
|
1792
2393
|
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
1793
2394
|
if (trialResult)
|
|
1794
2395
|
return trialResult;
|
|
1795
|
-
return await handleAccessError(err, 'supplemental');
|
|
2396
|
+
return await handleAccessError(err, 'supplemental', userId, apiKey);
|
|
1796
2397
|
}
|
|
1797
2398
|
});
|
|
1798
2399
|
// --- get_expert_intelligence ---
|
|
@@ -1821,13 +2422,24 @@ sptCtx) {
|
|
|
1821
2422
|
const expertWithheld = await settleOrWithhold({ queryTypeCode: 'expert_intelligence', apiKey, userId: resolveUserId(userId, uid), query }, 'get_expert_intelligence');
|
|
1822
2423
|
if (expertWithheld)
|
|
1823
2424
|
return expertWithheld;
|
|
1824
|
-
|
|
2425
|
+
const searchedGraphs = getLiveGraphs().filter(g => g.graph_type === 'expert' || g.graph_type === 'industry report' || g.graph_type === 'analyst');
|
|
2426
|
+
const annotatedData = addCoverageAnnotation(data, query, searchedGraphs, limit, false, getGraphs(), {
|
|
2427
|
+
total: data?.total,
|
|
2428
|
+
onTopicTotal: data?.on_topic_total,
|
|
2429
|
+
knownBrand: getKnownBrand(),
|
|
2430
|
+
});
|
|
2431
|
+
sessionTracker.postGapToSlack(resolveUserId(userId, uid), 'get_expert_intelligence', query, annotatedData?.coverage);
|
|
2432
|
+
logQueryResult(query, 'expert_intelligence', annotatedData?.coverage, searchedGraphs, annotatedData?.next_moves);
|
|
2433
|
+
if (annotatedData?.coverage?.status === 'error' || annotatedData?.error) {
|
|
2434
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify(annotatedData, null, 2) }] };
|
|
2435
|
+
}
|
|
2436
|
+
return { content: [{ type: 'text', text: JSON.stringify(annotatedData, null, 2) }] };
|
|
1825
2437
|
}
|
|
1826
2438
|
catch (err) {
|
|
1827
2439
|
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
1828
2440
|
if (trialResult)
|
|
1829
2441
|
return trialResult;
|
|
1830
|
-
return await handleAccessError(err, 'supplemental');
|
|
2442
|
+
return await handleAccessError(err, 'supplemental', userId, apiKey);
|
|
1831
2443
|
}
|
|
1832
2444
|
});
|
|
1833
2445
|
// --- get_report_intelligence ---
|
|
@@ -1856,18 +2468,29 @@ sptCtx) {
|
|
|
1856
2468
|
const reportWithheld = await settleOrWithhold({ queryTypeCode: 'report_intelligence', apiKey, userId: resolveUserId(userId, uid), query }, 'get_report_intelligence');
|
|
1857
2469
|
if (reportWithheld)
|
|
1858
2470
|
return reportWithheld;
|
|
1859
|
-
|
|
2471
|
+
const searchedGraphs = getLiveGraphs().filter(g => g.graph_type === 'industry report');
|
|
2472
|
+
const annotatedData = addCoverageAnnotation(data, query, searchedGraphs, limit, false, getGraphs(), {
|
|
2473
|
+
total: data?.total,
|
|
2474
|
+
onTopicTotal: data?.on_topic_total,
|
|
2475
|
+
knownBrand: getKnownBrand(),
|
|
2476
|
+
});
|
|
2477
|
+
sessionTracker.postGapToSlack(resolveUserId(userId, uid), 'get_report_intelligence', query, annotatedData?.coverage);
|
|
2478
|
+
logQueryResult(query, 'report_intelligence', annotatedData?.coverage, searchedGraphs, annotatedData?.next_moves);
|
|
2479
|
+
if (annotatedData?.coverage?.status === 'error' || annotatedData?.error) {
|
|
2480
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify(annotatedData, null, 2) }] };
|
|
2481
|
+
}
|
|
2482
|
+
return { content: [{ type: 'text', text: JSON.stringify(annotatedData, null, 2) }] };
|
|
1860
2483
|
}
|
|
1861
2484
|
catch (err) {
|
|
1862
2485
|
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
1863
2486
|
if (trialResult)
|
|
1864
2487
|
return trialResult;
|
|
1865
|
-
return await handleAccessError(err, 'supplemental');
|
|
2488
|
+
return await handleAccessError(err, 'supplemental', userId, apiKey);
|
|
1866
2489
|
}
|
|
1867
2490
|
});
|
|
1868
2491
|
// --- search_statistics ---
|
|
1869
|
-
server.tool('search_statistics', "
|
|
1870
|
-
graph_id: z.string().describe("Graph ID to search. Works on ALL graphs —
|
|
2492
|
+
server.tool('search_statistics', "HARD NUMBERS only: specific figures, 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 a number or statistic — try this BEFORE supplemental data tools, as Fodda's experts may have already curated the answer. For expert quotes, editorial analysis, and narrative interpretation, use search_insights instead. Works on ALL graphs — domain, expert, and report. Search multiple graphs for best coverage. Price: $0.50 per search.", {
|
|
2493
|
+
graph_id: z.string().describe("Graph ID to search. Works on ALL graphs — domain graphs ('retail', 'fashion', 'beauty', 'sports', 'sic', 'ce-design', 'pew') AND expert graphs. Search across multiple graphs for best coverage."),
|
|
1871
2494
|
query: z.string().describe("What data to search for (e.g., 'luxury resale market size', 'secondhand clothing sales volume', 'Gen Z spending behavior')"),
|
|
1872
2495
|
limit: z.number().optional().describe('Max results to return (default: 10, max: 50)'),
|
|
1873
2496
|
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.'),
|
|
@@ -1894,20 +2517,31 @@ sptCtx) {
|
|
|
1894
2517
|
const statsWithheld = await settleOrWithhold({ queryTypeCode: 'standalone_statistics', apiKey, userId: resolveUserId(userId, uid), query }, 'search_statistics');
|
|
1895
2518
|
if (statsWithheld)
|
|
1896
2519
|
return statsWithheld;
|
|
1897
|
-
|
|
2520
|
+
const searchedGraphs = [getGraphs().find(g => g.graph_id === graph_id)].filter(Boolean);
|
|
2521
|
+
const annotatedData = addCoverageAnnotation(data, query, searchedGraphs, limit, true, getGraphs(), {
|
|
2522
|
+
total: data?.total,
|
|
2523
|
+
onTopicTotal: data?.on_topic_total,
|
|
2524
|
+
knownBrand: getKnownBrand(),
|
|
2525
|
+
});
|
|
2526
|
+
sessionTracker.postGapToSlack(resolveUserId(userId, uid), 'search_statistics', query, annotatedData?.coverage);
|
|
2527
|
+
logQueryResult(query, 'search_statistics', annotatedData?.coverage, searchedGraphs, annotatedData?.next_moves);
|
|
2528
|
+
if (annotatedData?.coverage?.status === 'error' || annotatedData?.error) {
|
|
2529
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify(annotatedData, null, 2) }] };
|
|
2530
|
+
}
|
|
2531
|
+
return { content: [{ type: 'text', text: JSON.stringify(annotatedData, null, 2) }] };
|
|
1898
2532
|
}
|
|
1899
2533
|
catch (err) {
|
|
1900
2534
|
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
1901
2535
|
if (trialResult)
|
|
1902
2536
|
return trialResult;
|
|
1903
|
-
return await handleAccessError(err, 'supplemental');
|
|
2537
|
+
return await handleAccessError(err, 'supplemental', userId, apiKey);
|
|
1904
2538
|
}
|
|
1905
2539
|
});
|
|
1906
2540
|
// --- search_insights ---
|
|
1907
|
-
server.tool('search_insights',
|
|
1908
|
-
graph_id: z.string().describe("Graph ID to search. Works on ALL graphs —
|
|
2541
|
+
server.tool('search_insights', "NARRATIVE only: expert quotes, editorial analysis, and strategic perspectives on a topic — sourced from named strategists and industry leaders. Returns qualitative evidence (quotes, interpretations) with source attribution and parent trend context, NOT raw numbers. For hard data points, market sizes, and growth rates, use search_statistics instead. Works on ALL graphs. Use when you need authoritative voices, strategic framing, or analytical depth that web search cannot provide. Price: $0.50 per search.", {
|
|
2542
|
+
graph_id: z.string().describe("Graph ID to search. Works on ALL graphs — domain graphs ('retail', 'sic', 'beauty', 'sports', 'fashion', 'ce-design', 'pew') AND expert graphs. Search across multiple graphs for best coverage."),
|
|
1909
2543
|
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:
|
|
2544
|
+
types: z.string().optional().describe("Comma-separated evidence types to search: quote, interpretation, signal, metric, or 'all' (default: 'quote,interpretation' — narrative. For hard numbers, use search_statistics or add 'metric')."),
|
|
1911
2545
|
limit: z.number().optional().describe('Max results to return (default: 10, max: 50)'),
|
|
1912
2546
|
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
2547
|
userId: z.string().optional().describe('Optional user identifier for trial usage tracking.')
|
|
@@ -1915,7 +2549,7 @@ sptCtx) {
|
|
|
1915
2549
|
try {
|
|
1916
2550
|
// Log query to Questions table (fire-and-forget, before cache)
|
|
1917
2551
|
logUserQuery(query, 'search_insights', graph_id);
|
|
1918
|
-
const searchTypes = types || '
|
|
2552
|
+
const searchTypes = types || 'quote,interpretation';
|
|
1919
2553
|
const params = new URLSearchParams();
|
|
1920
2554
|
params.set('query', query);
|
|
1921
2555
|
params.set('types', searchTypes);
|
|
@@ -1928,20 +2562,104 @@ sptCtx) {
|
|
|
1928
2562
|
const insightsWithheld = await settleOrWithhold({ queryTypeCode: 'standalone_insights', apiKey, userId: resolveUserId(userId, uid), query }, 'search_insights');
|
|
1929
2563
|
if (insightsWithheld)
|
|
1930
2564
|
return insightsWithheld;
|
|
1931
|
-
|
|
2565
|
+
const searchedGraphs = [getGraphs().find(g => g.graph_id === graph_id)].filter(Boolean);
|
|
2566
|
+
const annotatedData = addCoverageAnnotation(data, query, searchedGraphs, limit, true, getGraphs(), {
|
|
2567
|
+
total: data?.total,
|
|
2568
|
+
onTopicTotal: data?.on_topic_total,
|
|
2569
|
+
knownBrand: getKnownBrand(),
|
|
2570
|
+
});
|
|
2571
|
+
sessionTracker.postGapToSlack(resolveUserId(userId, uid), 'search_insights', query, annotatedData?.coverage);
|
|
2572
|
+
logQueryResult(query, 'search_insights', annotatedData?.coverage, searchedGraphs, annotatedData?.next_moves);
|
|
2573
|
+
if (annotatedData?.coverage?.status === 'error' || annotatedData?.error) {
|
|
2574
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify(annotatedData, null, 2) }] };
|
|
2575
|
+
}
|
|
2576
|
+
return { content: [{ type: 'text', text: JSON.stringify(annotatedData, null, 2) }] };
|
|
1932
2577
|
}
|
|
1933
2578
|
catch (err) {
|
|
1934
2579
|
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
1935
2580
|
if (trialResult)
|
|
1936
2581
|
return trialResult;
|
|
1937
|
-
return await handleAccessError(err, 'supplemental');
|
|
2582
|
+
return await handleAccessError(err, 'supplemental', userId, apiKey);
|
|
1938
2583
|
}
|
|
1939
2584
|
});
|
|
2585
|
+
// ------------------------------------------------------------------
|
|
2586
|
+
// LinkedIn content tools — thin heads on the shared evidence engine
|
|
2587
|
+
// (src/linkedinEngine.ts). Server curates, client composes. The pack +
|
|
2588
|
+
// composition contract is the product; NO finished text is generated here.
|
|
2589
|
+
// ------------------------------------------------------------------
|
|
2590
|
+
/** Shared handler body for both LinkedIn tool heads. */
|
|
2591
|
+
const runLinkedInHead = async (toolName, queryTypeCode, engineOpts, uid) => {
|
|
2592
|
+
try {
|
|
2593
|
+
logUserQuery(engineOpts.topic, queryTypeCode);
|
|
2594
|
+
// SPT pre-run coverage: refuse before spending compute
|
|
2595
|
+
const guard = sptGuard(queryTypeCode);
|
|
2596
|
+
if (guard)
|
|
2597
|
+
return guard;
|
|
2598
|
+
const pack = await buildEvidencePack({ foddaRequest, apiKey, userId: resolveUserId(userId, uid) }, engineOpts);
|
|
2599
|
+
// Metered as ONE content call per unique request (settlement gates
|
|
2600
|
+
// delivery for SPT). Identical-request cache hits within 24h are
|
|
2601
|
+
// FREE — you never pay twice for the same answer. Retry/iterate
|
|
2602
|
+
// friendly and un-farmable: any change to topic/angle/voice/brand/
|
|
2603
|
+
// sub-themes is a different cache key → fresh retrieval → billed.
|
|
2604
|
+
const cacheHit = pack?._cache?.hit === true;
|
|
2605
|
+
if (!cacheHit) {
|
|
2606
|
+
const withheld = await settleOrWithhold({ queryTypeCode, apiKey, userId: resolveUserId(userId, uid), query: engineOpts.topic }, toolName);
|
|
2607
|
+
if (withheld)
|
|
2608
|
+
return withheld;
|
|
2609
|
+
}
|
|
2610
|
+
return { content: [{ type: 'text', text: JSON.stringify(pack, null, 2) }] };
|
|
2611
|
+
}
|
|
2612
|
+
catch (err) {
|
|
2613
|
+
// ── Explicit quota state: refuse to compose from a starved pack ──
|
|
2614
|
+
// The engine aborts the whole run the moment ANY retrieval call hits
|
|
2615
|
+
// CREDITS_EXHAUSTED / PLAN_LIMIT_EXCEEDED — no evidence pack exists,
|
|
2616
|
+
// so nothing thin or padded can ever reach the client model.
|
|
2617
|
+
if (err instanceof QuotaExhaustedError) {
|
|
2618
|
+
const refusal = {
|
|
2619
|
+
type: 'text',
|
|
2620
|
+
text: `LINKEDIN_DRAFT_REFUSED: evidence retrieval hit the account's quota limit mid-run. No evidence pack was produced — do NOT draft a ${engineOpts.mode} from partial or remembered data. Resolve the quota state below, then call ${toolName} again.`,
|
|
2621
|
+
};
|
|
2622
|
+
const trialResult = await handleTrialCreditExhaustion(err.causeErr, apiKey, userId);
|
|
2623
|
+
if (trialResult)
|
|
2624
|
+
return { ...trialResult, content: [refusal, ...trialResult.content] };
|
|
2625
|
+
const accessResult = await handleAccessError(err.causeErr, toolName, userId, apiKey);
|
|
2626
|
+
return { ...accessResult, content: [refusal, ...accessResult.content] };
|
|
2627
|
+
}
|
|
2628
|
+
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
2629
|
+
if (trialResult)
|
|
2630
|
+
return trialResult;
|
|
2631
|
+
const msg = err.response?.data?.error?.message || err.response?.data?.message || err.message;
|
|
2632
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
|
|
2633
|
+
}
|
|
2634
|
+
};
|
|
2635
|
+
// --- draft_linkedin_post ---
|
|
2636
|
+
server.tool('draft_linkedin_post', 'Draft a LinkedIn post about any topic, grounded in Fodda\'s expert knowledge graphs. Use when the user says "draft a LinkedIn post about…", "write a post on…", "turn this into a LinkedIn post", or wants social content backed by receipts. Returns a curated EVIDENCE PACK (claims with named companies, typed sources, and real URLs — never constructed) plus a strict composition contract; YOU write the post from it. Every claim is verifiable, thin coverage is flagged honestly, and dropped themes are logged with reasons. Bills as one content call; identical re-requests within 24h serve from cache free.', {
|
|
2637
|
+
topic: z.string().describe("The topic to post about (e.g., 'agentic commerce', 'retail media networks')"),
|
|
2638
|
+
angle: z.string().optional().describe('Optional thesis, or a post being responded to'),
|
|
2639
|
+
voice: z.enum(['fodda_first_party', 'practitioner']).optional().describe("Bridge-line voice: 'fodda_first_party' (\"We found these using Fodda…\", default) or 'practitioner' (\"I pulled these from Fodda…\") for users posting about their own industry."),
|
|
2640
|
+
sub_themes: z.array(z.string()).optional().describe("2–4 SPECIFIC sub-themes decomposing the topic (e.g. for 'agentic commerce': ['AI shopping agents checkout', 'retailer agent APIs', 'agent-to-agent payments']). Specific grounded queries consistently beat one broad query — supply these for best results."),
|
|
2641
|
+
brand: z.string().optional().describe("Set ONLY when the topic IS a named brand/company (e.g. 'Nike'). Unlocks the earnings truth layer: analyst concerns, CEO quotes, and market-validated trends as verbal-attribution evidence."),
|
|
2642
|
+
userId: z.string().optional().describe('Optional user identifier for usage tracking.'),
|
|
2643
|
+
}, { title: 'Draft LinkedIn Post (Evidence Pack)', readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ topic, angle, voice, sub_themes, brand, userId: uid }) => runLinkedInHead('draft_linkedin_post', 'linkedin_post', {
|
|
2644
|
+
mode: 'post', topic, angle, voice, brand, subThemes: sub_themes,
|
|
2645
|
+
}, uid));
|
|
2646
|
+
// --- draft_linkedin_article ---
|
|
2647
|
+
server.tool('draft_linkedin_article', 'Turn research into a LinkedIn ARTICLE (800–1,200 words) grounded in Fodda\'s expert knowledge graphs. Use when the user says "turn this research into an article…", "write a LinkedIn article about…", or wants long-form thought leadership with receipts. Runs a broader evidence sweep than the post tool — 3–5 sub-themes, a hard-numbers statistics pass, and an analyst pressure-test of the thesis — and returns a curated EVIDENCE PACK plus a strict composition contract; YOU write the article from it, including the "How we found this" methodology box. Bills as one content call; identical re-requests within 24h serve from cache free.', {
|
|
2648
|
+
topic: z.string().describe("The article topic (e.g., 'the rise of agentic commerce')"),
|
|
2649
|
+
thesis: z.string().optional().describe('The argument the article should make — gets pressure-tested by a Fodda analyst before drafting'),
|
|
2650
|
+
voice: z.enum(['fodda_first_party', 'practitioner']).optional().describe("Bridge-line voice: 'fodda_first_party' (default) or 'practitioner'."),
|
|
2651
|
+
target_length: z.number().optional().describe('Target word count (default ~1,000; contract allows ±20%)'),
|
|
2652
|
+
sub_themes: z.array(z.string()).optional().describe('3–5 SPECIFIC sub-themes decomposing the topic. Specific grounded queries consistently beat one broad query — supply these for best results.'),
|
|
2653
|
+
brand: z.string().optional().describe("Set ONLY when the topic IS a named brand/company. Unlocks the earnings truth layer (analyst concerns, CEO quotes, market-validated trends)."),
|
|
2654
|
+
userId: z.string().optional().describe('Optional user identifier for usage tracking.'),
|
|
2655
|
+
}, { title: 'Draft LinkedIn Article (Evidence Pack)', readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ topic, thesis, voice, target_length, sub_themes, brand, userId: uid }) => runLinkedInHead('draft_linkedin_article', 'linkedin_article', {
|
|
2656
|
+
mode: 'article', topic, angle: thesis, voice, brand, subThemes: sub_themes, targetLengthWords: target_length,
|
|
2657
|
+
}, uid));
|
|
1940
2658
|
// --- get_earnings_intelligence ---
|
|
1941
2659
|
// Cross-company and industry-level earnings call intelligence.
|
|
1942
2660
|
// For single-brand earnings, brand_tracker already includes earningsIntelligence.
|
|
1943
2661
|
// This tool is for: multi-company comparisons, industry/sector filters, and explicit earnings queries.
|
|
1944
|
-
server.tool('get_earnings_intelligence', '
|
|
2662
|
+
server.tool('get_earnings_intelligence', 'Cross-company thematic earnings intelligence from the knowledge graph and web sources. Use for multi-company comparisons ("what are hotel companies saying about labor costs?"), industry-level queries, or sector filters. For single-brand earnings, brand_tracker includes earnings automatically. For per-ticker structured analysis (analyst concerns, activity breakdown, validated consumer trends), use get_company_earnings instead — it reads the canonical truth layer. Results may include "knowledge_graph" or "web_supplemental" provenance. Price: $30 per query.', {
|
|
1945
2663
|
ticker: z.string().optional().describe("Company stock ticker (e.g., 'NKE', 'LVMUY', 'HLT'). At least one filter required."),
|
|
1946
2664
|
brand: z.string().optional().describe("Brand name for fuzzy matching (e.g., 'Nike', 'Marriott')"),
|
|
1947
2665
|
industry: z.string().optional().describe("Industry filter (e.g., 'hotels', 'sportswear', 'consumer electronics')"),
|
|
@@ -1987,13 +2705,13 @@ sptCtx) {
|
|
|
1987
2705
|
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
1988
2706
|
if (trialResult)
|
|
1989
2707
|
return trialResult;
|
|
1990
|
-
return await handleAccessError(err, 'supplemental');
|
|
2708
|
+
return await handleAccessError(err, 'supplemental', userId, apiKey);
|
|
1991
2709
|
}
|
|
1992
2710
|
});
|
|
1993
2711
|
// --- get_earnings_divergence ---
|
|
1994
2712
|
// Gaps between what analysts are concerned about and how management responds.
|
|
1995
2713
|
// This is premium intelligence — surfaces deflection and narrative mismatches.
|
|
1996
|
-
server.tool('get_earnings_divergence', '
|
|
2714
|
+
server.tool('get_earnings_divergence', 'Cross-company analyst-management divergence detection from the knowledge graph (legacy-thematic). Surfaces where executives are deflecting, reframing, or avoiding specific topics — the gap between what analysts press on and how management responds. Use for "where are executives deflecting?" or "divergence in [sector] earnings." For per-ticker deflection signals, use get_company_earnings with view=qa and filter by response_directness. Price: $20 per query.', {
|
|
1997
2715
|
sector: z.string().optional().describe("Sector filter (e.g., 'retail', 'technology', 'travel')"),
|
|
1998
2716
|
industry: z.string().optional().describe("Industry filter (e.g., 'hotels', 'sportswear', 'luxury')"),
|
|
1999
2717
|
search: z.string().optional().describe("Free text search (e.g., 'tariffs', 'AI capex', 'margin erosion')"),
|
|
@@ -2033,14 +2751,15 @@ sptCtx) {
|
|
|
2033
2751
|
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
2034
2752
|
if (trialResult)
|
|
2035
2753
|
return trialResult;
|
|
2036
|
-
return await handleAccessError(err, 'supplemental');
|
|
2754
|
+
return await handleAccessError(err, 'supplemental', userId, apiKey);
|
|
2037
2755
|
}
|
|
2038
2756
|
});
|
|
2039
2757
|
// --- get_company_earnings ---
|
|
2040
2758
|
// Per-ticker earnings intelligence: SWOT scores, sentiment, guidance, Q&A, competitive analysis.
|
|
2041
2759
|
// One tool with a `view` parameter — not 5 separate tools (context budget).
|
|
2042
|
-
server.tool('get_company_earnings', '
|
|
2043
|
-
|
|
2760
|
+
server.tool('get_company_earnings', 'The canonical per-ticker earnings source. Returns the full truth-layer record for covered tickers (517 consumer-sector companies) — analyst concerns, sentiment labels, strategic activity (marketing/retail/technology/sustainability), CEO intelligence, and validated consumer trends from Fodda\'s quarterly analysis pipeline. Falls back to web-backfill for uncovered tickers. Price: $20 per query (coverage view is free). Use this for company-specific data. Use get_earnings_intelligence for cross-company thematic comparisons.', {
|
|
2761
|
+
mode: z.enum(['snapshot', 'history', 'qa', 'compare', 'coverage', 'guidance']).optional().describe('Execution mode (alias for view): snapshot, history, qa, compare, guidance, coverage (free). Defaults to snapshot.'),
|
|
2762
|
+
view: z.enum(['snapshot', 'history', 'qa', 'compare', 'coverage', 'guidance']).optional().default('snapshot').describe('snapshot: full quarterly record with analyst concerns, sentiment, activity, validated trends. history: narrative timeline across quarters. qa: per-analyst Q&A entries with thematic tagging and response directness. compare: side-by-side comparison of 2-5 tickers. coverage: list all covered tickers (free).'),
|
|
2044
2763
|
ticker: z.string().optional().describe("Company ticker symbol (e.g. NKE, LULU, ONON). Required for snapshot, history, and qa views."),
|
|
2045
2764
|
tickers: z.string().optional().describe("Comma-separated ticker symbols for compare view (2-5 tickers). Only used when view=compare."),
|
|
2046
2765
|
period: z.string().optional().describe("Quarter filter (e.g. Q1-2026). Defaults to latest quarter."),
|
|
@@ -2048,20 +2767,21 @@ sptCtx) {
|
|
|
2048
2767
|
analyst: z.string().optional().describe("Analyst name filter for qa view."),
|
|
2049
2768
|
sector: z.string().optional().describe("Sector filter for guidance view."),
|
|
2050
2769
|
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 }) => {
|
|
2770
|
+
}, { title: 'Get Company Earnings', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ mode, view, ticker, tickers, period, metrics, analyst, sector, userId: uid }) => {
|
|
2771
|
+
const effectiveView = mode || view || 'snapshot';
|
|
2052
2772
|
try {
|
|
2053
2773
|
logUserQuery(ticker || tickers || sector || view || 'company earnings', 'earnings_company');
|
|
2054
2774
|
// ── Validation: require params per view ──
|
|
2055
|
-
if ((
|
|
2056
|
-
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'MISSING_PARAM', message: `The "${
|
|
2775
|
+
if ((effectiveView === 'snapshot' || effectiveView === 'history' || effectiveView === 'qa') && !ticker) {
|
|
2776
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'MISSING_PARAM', message: `The "${effectiveView}" view requires a ticker parameter (e.g. ticker: "NKE").` }) }] };
|
|
2057
2777
|
}
|
|
2058
|
-
if (
|
|
2778
|
+
if (effectiveView === 'compare' && !tickers) {
|
|
2059
2779
|
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
2780
|
}
|
|
2061
2781
|
// ── Route to API endpoint per view ──
|
|
2062
2782
|
let path;
|
|
2063
2783
|
let queryTypeCode;
|
|
2064
|
-
switch (
|
|
2784
|
+
switch (effectiveView) {
|
|
2065
2785
|
case 'snapshot': {
|
|
2066
2786
|
const params = new URLSearchParams();
|
|
2067
2787
|
if (period)
|
|
@@ -2100,17 +2820,6 @@ sptCtx) {
|
|
|
2100
2820
|
queryTypeCode = 'earnings_compare';
|
|
2101
2821
|
break;
|
|
2102
2822
|
}
|
|
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
2823
|
case 'coverage': {
|
|
2115
2824
|
// Free endpoint — no billing
|
|
2116
2825
|
const data = await foddaRequest('GET', '/v1/earnings/coverage', apiKey, resolveUserId(userId, uid));
|
|
@@ -2135,7 +2844,43 @@ sptCtx) {
|
|
|
2135
2844
|
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
2136
2845
|
if (trialResult)
|
|
2137
2846
|
return trialResult;
|
|
2138
|
-
return await handleAccessError(err, 'supplemental');
|
|
2847
|
+
return await handleAccessError(err, 'supplemental', userId, apiKey);
|
|
2848
|
+
}
|
|
2849
|
+
});
|
|
2850
|
+
// --- get_validated_trends ---
|
|
2851
|
+
server.tool('get_validated_trends', 'Returns market-validated consumer trends from corporate earnings reports cross-validated by Fodda\'s analysis pipeline. Connects earnings commentary (analyst concerns, CEO statements) with consumer trend signals. Price: $25 per query.', {
|
|
2852
|
+
ticker: z.string().optional().describe("Filter by company ticker symbol (e.g. 'NKE', 'LULU')."),
|
|
2853
|
+
sector: z.string().optional().describe("Filter by sector (e.g. 'retail', 'sportswear', 'beauty')."),
|
|
2854
|
+
search: z.string().optional().describe("Free text search in validated trends (e.g. 'resale', 'inventory', 'pricing')."),
|
|
2855
|
+
limit: z.number().int().optional().describe('Max results to return (default 20, max 50)'),
|
|
2856
|
+
userId: z.string().optional().describe('Optional user identifier for trial usage tracking.'),
|
|
2857
|
+
}, { title: 'Get Validated Consumer Trends from Earnings', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ ticker, sector, search, limit, userId: uid }) => {
|
|
2858
|
+
try {
|
|
2859
|
+
logUserQuery(search || ticker || sector || 'validated trends', 'get_validated_trends');
|
|
2860
|
+
const params = new URLSearchParams();
|
|
2861
|
+
if (ticker)
|
|
2862
|
+
params.set('ticker', ticker);
|
|
2863
|
+
if (sector)
|
|
2864
|
+
params.set('sector', sector);
|
|
2865
|
+
if (search)
|
|
2866
|
+
params.set('search', search);
|
|
2867
|
+
if (limit !== undefined)
|
|
2868
|
+
params.set('limit', String(Math.min(limit, 50)));
|
|
2869
|
+
const qs = params.toString();
|
|
2870
|
+
const guard = sptGuard('earnings_intelligence');
|
|
2871
|
+
if (guard)
|
|
2872
|
+
return guard;
|
|
2873
|
+
const data = await foddaRequest('GET', `/v1/earnings/validated-trends${qs ? '?' + qs : ''}`, apiKey, resolveUserId(userId, uid));
|
|
2874
|
+
const withheld = await settleOrWithhold({ queryTypeCode: 'earnings_intelligence', apiKey, userId: resolveUserId(userId, uid), query: search || ticker || sector || 'validated_trends' }, 'get_validated_trends');
|
|
2875
|
+
if (withheld)
|
|
2876
|
+
return withheld;
|
|
2877
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
2878
|
+
}
|
|
2879
|
+
catch (err) {
|
|
2880
|
+
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
2881
|
+
if (trialResult)
|
|
2882
|
+
return trialResult;
|
|
2883
|
+
return await handleAccessError(err, 'supplemental', userId, apiKey);
|
|
2139
2884
|
}
|
|
2140
2885
|
});
|
|
2141
2886
|
// --- update_user_profile ---
|
|
@@ -2221,30 +2966,36 @@ sptCtx) {
|
|
|
2221
2966
|
exit_reason: '🚪',
|
|
2222
2967
|
complaint: '😤',
|
|
2223
2968
|
};
|
|
2224
|
-
server.tool('send_feedback', 'Forward user feedback, feature requests, complaints, or
|
|
2969
|
+
server.tool('send_feedback', 'Forward user feedback, feature requests, complaints, or prompt/answer quality issues to the Fodda team via email and Slack. Call this whenever a user shares feedback or expresses dissatisfaction — optionally include recent_prompt to capture the context.', {
|
|
2225
2970
|
feedback: z.string().describe('The user\'s feedback, complaint, suggestion, or exit reason'),
|
|
2226
2971
|
user_email: z.string().optional().describe('User\'s email if known (for follow-up)'),
|
|
2227
2972
|
category: z.string().optional().describe("Category: 'feedback', 'bug', 'feature_request', 'exit_reason', 'complaint'"),
|
|
2228
|
-
|
|
2973
|
+
recent_prompt: z.string().optional().describe('The prompt or question that generated the answer being commented on'),
|
|
2974
|
+
}, { title: 'Send Feedback', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ feedback, user_email, category, recent_prompt }) => {
|
|
2229
2975
|
try {
|
|
2230
2976
|
const userLabel = user_email || (userId !== 'anonymous' ? userId : 'anonymous trial user');
|
|
2231
2977
|
const entryLabel = entryId ? ` (entry: ${entryId})` : '';
|
|
2232
2978
|
const catLabel = category || 'general';
|
|
2233
2979
|
const emoji = FEEDBACK_CATEGORY_EMOJI[catLabel] || '💬';
|
|
2980
|
+
// Record in local telemetry feedback buffer
|
|
2981
|
+
recordFeedbackEntry(catLabel, feedback, userLabel);
|
|
2234
2982
|
// ── Slack alert (fire-and-forget) ──
|
|
2235
|
-
const
|
|
2983
|
+
const slackLines = [
|
|
2236
2984
|
`<@U0AU49JG7AS> ${emoji} *User Feedback*`,
|
|
2237
2985
|
`👤 ${userLabel}`,
|
|
2238
2986
|
`📁 Category: ${catLabel}`,
|
|
2239
2987
|
`📝 ${feedback}`,
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2988
|
+
];
|
|
2989
|
+
if (recent_prompt) {
|
|
2990
|
+
slackLines.push(`❓ *Prompt Context:* ${recent_prompt}`);
|
|
2991
|
+
}
|
|
2992
|
+
slackLines.push(`→ Check if this needs a response or product action.`);
|
|
2993
|
+
postToSlack(slackLines.join('\n')).catch(() => { });
|
|
2243
2994
|
// ── Resend email ──
|
|
2244
2995
|
const resendKey = process.env.RESEND_API_KEY;
|
|
2245
2996
|
if (!resendKey) {
|
|
2246
2997
|
console.error('[send_feedback] RESEND_API_KEY not set — logging feedback locally');
|
|
2247
|
-
console.error(`[FEEDBACK] category=${catLabel} email=${userLabel} feedback=${feedback}`);
|
|
2998
|
+
console.error(`[FEEDBACK] category=${catLabel} email=${userLabel} feedback=${feedback}${recent_prompt ? ` prompt=${recent_prompt}` : ''}`);
|
|
2248
2999
|
return {
|
|
2249
3000
|
content: [{
|
|
2250
3001
|
type: 'text',
|
|
@@ -2257,11 +3008,13 @@ sptCtx) {
|
|
|
2257
3008
|
const subject = `[Fodda MCP Feedback] ${catLabel} from ${userLabel}`;
|
|
2258
3009
|
await resend.emails.send({
|
|
2259
3010
|
from: 'Fodda MCP <feedback@fodda.ai>',
|
|
2260
|
-
to: ['piers@
|
|
3011
|
+
to: ['piers.fawkes@psfk.com'],
|
|
3012
|
+
cc: ['team@fodda.ai'],
|
|
2261
3013
|
subject,
|
|
2262
3014
|
text: [
|
|
2263
3015
|
`Category: ${catLabel}`,
|
|
2264
3016
|
`User: ${userLabel}${entryLabel}`,
|
|
3017
|
+
`Prompt Context: ${recent_prompt || 'N/A'}`,
|
|
2265
3018
|
`API Key: ${apiKey.substring(0, 15)}...`,
|
|
2266
3019
|
`Date: ${new Date().toISOString()}`,
|
|
2267
3020
|
'',
|
|
@@ -2292,10 +3045,17 @@ sptCtx) {
|
|
|
2292
3045
|
const APP_BASE_URL = process.env.FODDA_APP_URL || 'https://app.fodda.ai';
|
|
2293
3046
|
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).', {
|
|
2294
3047
|
email: z.string().describe('User\'s email address (required)'),
|
|
3048
|
+
user_confirmed: z.literal(true).describe('Must be true — the user must have explicitly asked to create an account before this tool is called. Never set this to true speculatively or on behalf of the user.'),
|
|
2295
3049
|
name: z.string().optional().describe('User\'s full name (optional — collect conversationally after signup)'),
|
|
2296
3050
|
job_title: z.string().optional().describe('User\'s job title (optional)'),
|
|
2297
3051
|
company: z.string().optional().describe('User\'s company name (optional)'),
|
|
2298
|
-
}, { title: 'Create Base Account', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ email, name, job_title, company }) => {
|
|
3052
|
+
}, { title: 'Create Base Account', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ email, user_confirmed, name, job_title, company }) => {
|
|
3053
|
+
if (!user_confirmed) {
|
|
3054
|
+
return {
|
|
3055
|
+
isError: true,
|
|
3056
|
+
content: [{ type: 'text', text: JSON.stringify({ error: 'user_confirmed must be true — only call this tool after the user has explicitly asked to create an account.' }) }],
|
|
3057
|
+
};
|
|
3058
|
+
}
|
|
2299
3059
|
try {
|
|
2300
3060
|
// Derive firstName from name or email prefix
|
|
2301
3061
|
const firstName = name
|
|
@@ -2313,9 +3073,9 @@ sptCtx) {
|
|
|
2313
3073
|
type: 'text',
|
|
2314
3074
|
text: JSON.stringify({
|
|
2315
3075
|
status: 'UPGRADED',
|
|
2316
|
-
message: `I've created your Base account — you'll get ${response.data.monthlyTokens || 100}
|
|
3076
|
+
message: `I've created your Base account — you'll get ${response.data.monthlyTokens || 100} API calls/month. Check your email to confirm and then log in at https://app.fodda.ai to grab your MCP URL and update your connector.`,
|
|
2317
3077
|
plan: response.data.plan || 'Base',
|
|
2318
|
-
|
|
3078
|
+
monthly_api_call_limit: response.data.monthlyTokens || 100,
|
|
2319
3079
|
graphId: response.data.graphId || null,
|
|
2320
3080
|
accountId: response.data.accountId || null,
|
|
2321
3081
|
}, null, 2)
|
|
@@ -2379,7 +3139,14 @@ sptCtx) {
|
|
|
2379
3139
|
try {
|
|
2380
3140
|
const body = { query, limit: 5, use_semantic: true, include_evidence: false };
|
|
2381
3141
|
const res = await foddaRequest('POST', `/v1/graphs/${encodeURIComponent(gid)}/search`, apiKey, resolvedUserId, body);
|
|
2382
|
-
return (res?.rows || []).map((r) => ({
|
|
3142
|
+
return (res?.rows || []).map((r) => ({
|
|
3143
|
+
...r,
|
|
3144
|
+
graphId: gid,
|
|
3145
|
+
_use_this_graphId: gid,
|
|
3146
|
+
_source_graph: gid,
|
|
3147
|
+
on_topic_total: res?.on_topic_total ?? res?.total ?? res?.total_count,
|
|
3148
|
+
total: res?.total ?? res?.total_count,
|
|
3149
|
+
}));
|
|
2383
3150
|
}
|
|
2384
3151
|
catch {
|
|
2385
3152
|
return [];
|
|
@@ -2529,10 +3296,23 @@ sptCtx) {
|
|
|
2529
3296
|
suggested_next_prompts: suggestedPrompts,
|
|
2530
3297
|
_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.',
|
|
2531
3298
|
};
|
|
3299
|
+
const brainstormNextMoves = generateNextMoves(allResults || [], query, graphIdsToSearch || [], allResults.length === 0 ? 'empty' : 'ok', undefined, undefined, getGraphs(), getAnalysts(), { knownBrand: getKnownBrand() });
|
|
3300
|
+
sessionTracker.recordNextMoves(brainstormNextMoves, query);
|
|
3301
|
+
brainstormMap.next_moves = brainstormNextMoves;
|
|
3302
|
+
const brainstormClosing = renderClosingBlock(brainstormNextMoves);
|
|
3303
|
+
const closingInstruction = brainstormClosing.text
|
|
3304
|
+
? `── NEXT MOVES CLOSING BLOCK (Render Spec 1.3) ──\nReproduce this exact 3-sentence closing block verbatim at the end of your answer (no heading, no "any questions?", no emoji, no apology):\n\n${brainstormClosing.lines.join('\n')}`
|
|
3305
|
+
: '';
|
|
2532
3306
|
// ── Query-level billing ──
|
|
2533
3307
|
chargeQuery({ queryTypeCode: 'brainstorm', apiKey, userId: resolveUserId(userId, uid), query, foddaRequest, spt: sptCtx?.token })
|
|
2534
3308
|
.catch(e => console.error('[brainstorm] chargeQuery failed:', e.message));
|
|
2535
|
-
|
|
3309
|
+
const content = [
|
|
3310
|
+
{ type: 'text', text: '── RAW DATA (for follow-up reasoning) ──\n' + JSON.stringify(brainstormMap, null, 2) },
|
|
3311
|
+
];
|
|
3312
|
+
if (closingInstruction) {
|
|
3313
|
+
content.push({ type: 'text', text: closingInstruction });
|
|
3314
|
+
}
|
|
3315
|
+
return { next_moves: brainstormNextMoves, content };
|
|
2536
3316
|
}
|
|
2537
3317
|
catch (err) {
|
|
2538
3318
|
const msg = err.message || 'Brainstorm execution failed.';
|
|
@@ -2585,7 +3365,7 @@ sptCtx) {
|
|
|
2585
3365
|
}
|
|
2586
3366
|
});
|
|
2587
3367
|
// --- manage_scheduled_reports ---
|
|
2588
|
-
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.
|
|
3368
|
+
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. Price: $20 per run. Supports topic research or brand intelligence report types.', {
|
|
2589
3369
|
action: z.enum(['create', 'list', 'cancel', 'update', 'pause', 'resume']),
|
|
2590
3370
|
query: z.string().optional().describe('For "create": the research query to run'),
|
|
2591
3371
|
email: z.string().optional().describe('Email address to deliver reports to'),
|
|
@@ -2670,7 +3450,7 @@ sptCtx) {
|
|
|
2670
3450
|
}
|
|
2671
3451
|
});
|
|
2672
3452
|
// --- read_url ---
|
|
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.
|
|
3453
|
+
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. Price: $20 per URL lookup.', {
|
|
2674
3454
|
url: z.string().describe('The URL to read and extract content from'),
|
|
2675
3455
|
userId: z.string().optional().describe('Optional user identifier for usage tracking.')
|
|
2676
3456
|
}, { title: 'Read URL Content', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, async ({ url, userId: uid }) => {
|
|
@@ -2686,7 +3466,14 @@ sptCtx) {
|
|
|
2686
3466
|
const outputs = result.outputs || [];
|
|
2687
3467
|
const textParts = outputs.filter((o) => o.type === 'text').map((o) => o.text);
|
|
2688
3468
|
const extractedText = textParts.join('\n');
|
|
2689
|
-
|
|
3469
|
+
// A model that cannot fetch the page replies with a refusal — non-empty
|
|
3470
|
+
// text that would otherwise pass as success and bill 15 calls for no
|
|
3471
|
+
// content. Only treat retrieval as failed on positive evidence: metadata
|
|
3472
|
+
// present and no URL reporting success.
|
|
3473
|
+
const urlMetadata = result.urlContextMetadata?.urlMetadata || [];
|
|
3474
|
+
const retrievalFailed = urlMetadata.length > 0
|
|
3475
|
+
&& !urlMetadata.some((m) => m.urlRetrievalStatus === 'URL_RETRIEVAL_STATUS_SUCCESS');
|
|
3476
|
+
if (!extractedText || retrievalFailed) {
|
|
2690
3477
|
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.' }) }] };
|
|
2691
3478
|
}
|
|
2692
3479
|
// ── Query-level billing ──
|
|
@@ -2718,18 +3505,18 @@ sptCtx) {
|
|
|
2718
3505
|
// Uses the fodda-researcher agent with 5 skill instruction files.
|
|
2719
3506
|
// Flow: Pre-fetch graph data → Build skill-loaded system instruction →
|
|
2720
3507
|
// Call Gemini directly via waverunnerRequest → Stream progress via sendLoggingMessage.
|
|
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.
|
|
2722
|
-
query: z.string().describe('The research
|
|
3508
|
+
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. Price: $55 (light mode) or $100 (heavy mode). 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.', {
|
|
3509
|
+
query: z.string().describe('The research subject as a short phrase, 5–15 words. Do not pass a full brief — long multi-clause queries degrade graph selection. Put detail into sub_themes instead.'),
|
|
3510
|
+
sub_themes: z.array(z.string()).optional().describe('3–5 specific angles to investigate (e.g. "category sizing and growth forecasts for wine coolers", "key players across appliance, furniture and glassware", "DTC versus wholesale channel dynamics"). If omitted, generated automatically. This is where research detail belongs — not in the query.'),
|
|
2723
3511
|
graphId: z.string().optional().describe('Optional specific graph ID to limit the research to'),
|
|
2724
|
-
|
|
3512
|
+
mode: z.enum(['light', 'heavy']).optional().describe('Research mode: "light" for faster research ($55), "heavy" for comprehensive deep dive ($100). Defaults to "light".'),
|
|
3513
|
+
depth: z.enum(['light', 'heavy']).optional().describe('Research depth: "light" for faster research ($55), "heavy" for comprehensive deep dive ($100). Defaults to "light".'),
|
|
2725
3514
|
userId: z.string().optional().describe('Optional user identifier.')
|
|
2726
|
-
}, { title: 'Deep Research Topic', readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true }, async ({ query, graphId, depth, userId: uid }) => {
|
|
3515
|
+
}, { title: 'Deep Research Topic', readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true }, async ({ query, sub_themes, graphId, mode, depth, userId: uid }) => {
|
|
2727
3516
|
const resolvedUserId = resolveUserId(userId, uid);
|
|
2728
|
-
const
|
|
2729
|
-
const
|
|
3517
|
+
const effectiveDepth = mode || depth || 'light';
|
|
3518
|
+
const isHeavy = effectiveDepth === 'heavy';
|
|
2730
3519
|
const queryTypeCode = isHeavy ? 'deep_research_heavy' : 'deep_research_light';
|
|
2731
|
-
const maxGraphs = isHeavy ? 15 : 8;
|
|
2732
|
-
const startTime = Date.now();
|
|
2733
3520
|
// ── SPT pre-run coverage: refuse before kicking off the (long, expensive) job ──
|
|
2734
3521
|
const researchGuard = sptGuard(queryTypeCode);
|
|
2735
3522
|
if (researchGuard)
|
|
@@ -2737,260 +3524,87 @@ sptCtx) {
|
|
|
2737
3524
|
try {
|
|
2738
3525
|
// Log query to Questions table (fire-and-forget, before cache)
|
|
2739
3526
|
logUserQuery(query, 'deep_research');
|
|
2740
|
-
// ── Phase 1: Planning ──
|
|
2741
|
-
await server.sendLoggingMessage({
|
|
2742
|
-
level: 'info',
|
|
2743
|
-
data: `📋 Phase 1/5: Planning research approach for "${query.slice(0, 80)}"...`,
|
|
2744
|
-
});
|
|
2745
|
-
console.error(`[deep_research_topic] Starting ${isHeavy ? 'heavy' : 'light'} research: "${query}"`);
|
|
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
|
-
];
|
|
2776
|
-
// ── Phase 2: Searching Fodda Knowledge Graphs ──
|
|
2777
|
-
await server.sendLoggingMessage({
|
|
2778
|
-
level: 'info',
|
|
2779
|
-
data: `🔍 Phase 2/5: Searching ${graphIds.length} knowledge graph${graphIds.length !== 1 ? 's' : ''}${earningsCandidate ? ' + earnings intelligence' : ''}...`,
|
|
2780
|
-
});
|
|
2781
|
-
// Pre-fetch graph data in parallel
|
|
2782
|
-
const graphSearchPromises = graphIds.map(async (gid) => {
|
|
2783
|
-
try {
|
|
2784
|
-
const searchBody = { query, limit: isHeavy ? 10 : 5, use_semantic: true, include_evidence: true };
|
|
2785
|
-
const res = await foddaRequest('POST', `/v1/graphs/${encodeURIComponent(gid)}/search`, apiKey, resolvedUserId, searchBody);
|
|
2786
|
-
const rows = res?.rows || [];
|
|
2787
|
-
const evidence = rows.flatMap((r) => r.evidence || []);
|
|
2788
|
-
return { graphId: gid, rows, evidence };
|
|
2789
|
-
}
|
|
2790
|
-
catch {
|
|
2791
|
-
return { graphId: gid, rows: [], evidence: [] };
|
|
2792
|
-
}
|
|
2793
|
-
});
|
|
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
|
-
]);
|
|
2821
|
-
const totalTrends = graphResults.reduce((sum, g) => sum + g.rows.length, 0);
|
|
2822
|
-
const totalEvidence = graphResults.reduce((sum, g) => sum + g.evidence.length, 0);
|
|
2823
|
-
const activeGraphs = graphResults.filter(g => g.rows.length > 0);
|
|
2824
|
-
// Graceful degradation: if all graph searches failed, the agent
|
|
2825
|
-
// proceeds using ONLY Google Search + URL Context. The report will
|
|
2826
|
-
// be web-only but still useful — no hard failure.
|
|
2827
|
-
if (activeGraphs.length === 0) {
|
|
2828
|
-
console.error(`[deep_research_topic] All ${graphIds.length} graph searches failed — proceeding with web-only research`);
|
|
2829
|
-
await server.sendLoggingMessage({
|
|
2830
|
-
level: 'warning',
|
|
2831
|
-
data: `⚠️ Knowledge graph search returned no results — proceeding with web-only research...`,
|
|
2832
|
-
});
|
|
2833
|
-
}
|
|
2834
|
-
else {
|
|
2835
|
-
await server.sendLoggingMessage({
|
|
2836
|
-
level: 'info',
|
|
2837
|
-
data: `📊 Found ${totalTrends} trend${totalTrends !== 1 ? 's' : ''} and ${totalEvidence} evidence pieces across ${activeGraphs.length} graph${activeGraphs.length !== 1 ? 's' : ''}. Launching deep analysis...`,
|
|
2838
|
-
});
|
|
2839
|
-
}
|
|
2840
|
-
// ── Phase 3: Deep Analysis via Gemini ──
|
|
2841
|
-
await server.sendLoggingMessage({
|
|
2842
|
-
level: 'info',
|
|
2843
|
-
data: `🧠 Phase 3/5: Deep analysis with web research — this takes 1-3 minutes...`,
|
|
2844
|
-
});
|
|
2845
|
-
// Build graph context for the agent
|
|
2846
|
-
const graphContext = {
|
|
2847
|
-
graphResults: JSON.stringify(graphResults.map(g => ({
|
|
2848
|
-
graph_id: g.graphId,
|
|
2849
|
-
trends: g.rows.map((r) => ({
|
|
2850
|
-
name: String(r.title || r.trendName || '').substring(0, 150),
|
|
2851
|
-
summary: String(r.summary || r.description || '').substring(0, 600),
|
|
2852
|
-
signal_score: r.signal_score || r.score,
|
|
2853
|
-
lifecycle: r.trendLifecycle || r.lifecycle,
|
|
2854
|
-
evidence: (r.evidence || []).slice(0, 3).map((e) => ({
|
|
2855
|
-
title: String(e.title || '').substring(0, 150),
|
|
2856
|
-
snippet: String(e.snippet || e.summary || '').substring(0, 400),
|
|
2857
|
-
source_url: e.sourceUrl || e.url,
|
|
2858
|
-
category: e.category || e.type,
|
|
2859
|
-
}))
|
|
2860
|
-
})),
|
|
2861
|
-
// Top-level evidence sample for cross-trend validation
|
|
2862
|
-
evidence: g.evidence.slice(0, isHeavy ? 10 : 5).map((e) => ({
|
|
2863
|
-
title: String(e.title || '').substring(0, 150),
|
|
2864
|
-
snippet: String(e.snippet || e.summary || '').substring(0, 400),
|
|
2865
|
-
source_url: e.sourceUrl || e.url,
|
|
2866
|
-
category: e.category || e.type,
|
|
2867
|
-
brand: e.brandNames?.[0] || e.brand,
|
|
2868
|
-
})),
|
|
2869
|
-
})), null, 2),
|
|
2870
|
-
graphsSearched: activeGraphs.map(g => g.graphId),
|
|
2871
|
-
totalTrends,
|
|
2872
|
-
totalEvidence,
|
|
2873
|
-
focusGraphId: graphId,
|
|
2874
|
-
// Distinct earnings block — attributed by source type at synthesis
|
|
2875
|
-
...(earningsData ? { earningsResults: JSON.stringify(earningsData).substring(0, 15000) } : {}),
|
|
2876
|
-
};
|
|
2877
|
-
// Build skill-loaded system instruction
|
|
2878
|
-
const systemInstruction = buildResearcherInstruction(query, graphContext);
|
|
2879
|
-
// Call Gemini directly via Waverunner with a timeout guard.
|
|
2880
|
-
// Without a timeout, a stuck Gemini call hangs the MCP tool response
|
|
2881
|
-
// indefinitely — the user sees silence until their client times out.
|
|
2882
|
-
const geminiModel = isHeavy ? 'gemini-2.5-pro' : 'gemini-2.5-flash';
|
|
2883
|
-
// Build the Interactions API payload.
|
|
2884
|
-
// SDK type: BaseCreateModelInteractionParams supports system_instruction
|
|
2885
|
-
// (snake_case) as a top-level parameter alongside model, input, tools.
|
|
2886
|
-
const interactionPayload = {
|
|
2887
|
-
model: geminiModel,
|
|
2888
|
-
system_instruction: systemInstruction,
|
|
2889
|
-
input: [
|
|
2890
|
-
{
|
|
2891
|
-
type: 'text',
|
|
2892
|
-
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.`,
|
|
2893
|
-
},
|
|
2894
|
-
],
|
|
2895
|
-
tools: [
|
|
2896
|
-
{ type: 'google_search' },
|
|
2897
|
-
{ type: 'url_context' },
|
|
2898
|
-
],
|
|
2899
|
-
};
|
|
2900
3527
|
const jobId = crypto.randomUUID();
|
|
2901
3528
|
activeResearchJobs.set(jobId, { status: 'RUNNING', result: null, error: null });
|
|
2902
|
-
|
|
3529
|
+
const jobTimeoutTimer = setTimeout(() => {
|
|
3530
|
+
const current = activeResearchJobs.get(jobId);
|
|
3531
|
+
if (current && current.status === 'RUNNING') {
|
|
3532
|
+
console.error(`[deep_research_topic] Job ${jobId} exceeded hard 240s execution ceiling — setting status to FAILED`);
|
|
3533
|
+
activeResearchJobs.set(jobId, {
|
|
3534
|
+
status: 'FAILED',
|
|
3535
|
+
error: 'Research job exceeded the 240-second maximum execution timeout.'
|
|
3536
|
+
});
|
|
3537
|
+
}
|
|
3538
|
+
}, 240000);
|
|
3539
|
+
// Run the extracted pipeline in the background to avoid Claude Web timeout
|
|
2903
3540
|
(async () => {
|
|
2904
3541
|
try {
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
else {
|
|
2920
|
-
throw primaryErr;
|
|
2921
|
-
}
|
|
2922
|
-
}
|
|
2923
|
-
// Extract text from Gemini response
|
|
2924
|
-
const outputs = result?.outputs || [];
|
|
2925
|
-
const textParts = outputs
|
|
2926
|
-
.filter((o) => o.type === 'text')
|
|
2927
|
-
.map((o) => o.text);
|
|
2928
|
-
let reportText = textParts.join('\n\n');
|
|
2929
|
-
// Extract URLs
|
|
2930
|
-
const seenUrls = new Set();
|
|
2931
|
-
const sourceUrls = [];
|
|
2932
|
-
for (const output of outputs) {
|
|
2933
|
-
if (output.type === 'text' && Array.isArray(output.annotations)) {
|
|
2934
|
-
for (const ann of output.annotations) {
|
|
2935
|
-
if (ann.type === 'url_citation' && ann.url && !seenUrls.has(ann.url)) {
|
|
2936
|
-
if (ann.url.includes('vertexaisearch.cloud.google.com'))
|
|
2937
|
-
continue;
|
|
2938
|
-
seenUrls.add(ann.url);
|
|
2939
|
-
sourceUrls.push({ title: ann.title || '', url: ann.url });
|
|
2940
|
-
}
|
|
2941
|
-
}
|
|
2942
|
-
}
|
|
2943
|
-
if (output.type === 'url_context_result' && Array.isArray(output.result)) {
|
|
2944
|
-
for (const ctx of output.result) {
|
|
2945
|
-
if (ctx.url && ctx.status === 'success' && !seenUrls.has(ctx.url)) {
|
|
2946
|
-
seenUrls.add(ctx.url);
|
|
2947
|
-
sourceUrls.push({ title: '', url: ctx.url });
|
|
2948
|
-
}
|
|
2949
|
-
}
|
|
2950
|
-
}
|
|
2951
|
-
}
|
|
2952
|
-
const groundingChunks = result?.groundingMetadata?.groundingChunks || [];
|
|
2953
|
-
for (const chunk of groundingChunks) {
|
|
2954
|
-
if (chunk?.web?.uri && !seenUrls.has(chunk.web.uri)) {
|
|
2955
|
-
seenUrls.add(chunk.web.uri);
|
|
2956
|
-
sourceUrls.push({ title: chunk.web.title || '', url: chunk.web.uri });
|
|
2957
|
-
}
|
|
2958
|
-
}
|
|
2959
|
-
if (sourceUrls.length > 0) {
|
|
2960
|
-
reportText += '\n\n## Sources\n' + sourceUrls.map(s => s.title ? `- [${s.title}](${s.url})` : `- ${s.url}`).join('\n');
|
|
2961
|
-
}
|
|
2962
|
-
if (!reportText) {
|
|
2963
|
-
activeResearchJobs.set(jobId, { status: 'FAILED', error: 'Research agent returned no output.' });
|
|
2964
|
-
return;
|
|
2965
|
-
}
|
|
2966
|
-
const durationSec = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
3542
|
+
const result = await runDeepResearch({
|
|
3543
|
+
query,
|
|
3544
|
+
subThemes: sub_themes,
|
|
3545
|
+
apiKey,
|
|
3546
|
+
userId: resolvedUserId,
|
|
3547
|
+
depth: effectiveDepth,
|
|
3548
|
+
graphId,
|
|
3549
|
+
foddaRequest,
|
|
3550
|
+
waverunnerRequest,
|
|
3551
|
+
onProgress: (msg) => {
|
|
3552
|
+
server.sendLoggingMessage({ level: 'info', data: msg }).catch(() => { });
|
|
3553
|
+
},
|
|
3554
|
+
});
|
|
3555
|
+
clearTimeout(jobTimeoutTimer);
|
|
2967
3556
|
// ── Settlement gates delivery for SPT: only mark COMPLETE once the charge succeeds. ──
|
|
2968
3557
|
if (sptCtx) {
|
|
2969
|
-
const r = await chargeQuery({ queryTypeCode, apiKey, userId: resolvedUserId, query, graphsSearched:
|
|
3558
|
+
const r = await chargeQuery({ queryTypeCode, apiKey, userId: resolvedUserId, query, graphsSearched: result.graphs_searched, foddaRequest, spt: sptCtx.token });
|
|
2970
3559
|
if (!r.charged) {
|
|
2971
3560
|
activeResearchJobs.set(jobId, { status: 'FAILED', error: r.error || 'Payment could not be completed; report withheld.' });
|
|
2972
3561
|
return;
|
|
2973
3562
|
}
|
|
2974
3563
|
}
|
|
2975
3564
|
else {
|
|
2976
|
-
chargeQuery({ queryTypeCode, apiKey, userId: resolvedUserId, query, graphsSearched:
|
|
3565
|
+
chargeQuery({ queryTypeCode, apiKey, userId: resolvedUserId, query, graphsSearched: result.graphs_searched, foddaRequest })
|
|
2977
3566
|
.catch(e => console.error('[deep_research_topic] chargeQuery failed:', e.message));
|
|
2978
3567
|
}
|
|
2979
|
-
|
|
2980
|
-
`_Research by Fodda Research Agent • ${activeGraphs.length} graph${activeGraphs.length !== 1 ? 's' : ''} searched • ${totalTrends} trends analyzed${earningsData ? ' • earnings intelligence included' : ''} • ${durationSec}s_`,
|
|
2981
|
-
'',
|
|
2982
|
-
].join('\n');
|
|
2983
|
-
activeResearchJobs.set(jobId, { status: 'COMPLETE', result: header + reportText });
|
|
3568
|
+
activeResearchJobs.set(jobId, { status: 'COMPLETE', result });
|
|
2984
3569
|
}
|
|
2985
3570
|
catch (err) {
|
|
3571
|
+
clearTimeout(jobTimeoutTimer);
|
|
2986
3572
|
const msg = err.response?.data?.error?.message || err.response?.data?.message || err.message;
|
|
2987
3573
|
activeResearchJobs.set(jobId, { status: 'FAILED', error: msg });
|
|
2988
3574
|
}
|
|
2989
3575
|
})();
|
|
3576
|
+
// Return source_plan immediately so the caller knows what's happening.
|
|
3577
|
+
const maxGraphs = isHeavy ? 15 : 8;
|
|
3578
|
+
const routingTopic = extractRoutingTopic(query);
|
|
3579
|
+
const previewCandidates = graphId ? [] : getRelevantSources(routingTopic, { minGraphs: isHeavy ? 6 : 4, maxGraphs });
|
|
3580
|
+
const previewGraphs = previewCandidates
|
|
3581
|
+
.filter((c) => c.kind === 'graph')
|
|
3582
|
+
.slice(0, maxGraphs);
|
|
3583
|
+
const previewEarnings = previewCandidates.find((c) => c.kind === 'earnings');
|
|
3584
|
+
const previewSupp = previewCandidates.filter((c) => c.kind === 'supplemental');
|
|
3585
|
+
const sourcePlan = [
|
|
3586
|
+
...(graphId
|
|
3587
|
+
? [{ kind: 'graph', id: graphId, reason: 'explicitly requested via graphId' }]
|
|
3588
|
+
: previewGraphs.map(c => ({ kind: 'graph', id: c.graphId, reason: c.reason }))),
|
|
3589
|
+
...(previewEarnings ? [{
|
|
3590
|
+
kind: 'earnings',
|
|
3591
|
+
...(previewEarnings.ticker ? { ticker: previewEarnings.ticker } : {}),
|
|
3592
|
+
...(previewEarnings.brand ? { brand: previewEarnings.brand } : {}),
|
|
3593
|
+
...(previewEarnings.sector ? { sector: previewEarnings.sector } : {}),
|
|
3594
|
+
reason: previewEarnings.reason,
|
|
3595
|
+
}] : []),
|
|
3596
|
+
...previewSupp.slice(0, 2).map(c => ({
|
|
3597
|
+
kind: 'supplemental', category: c.category,
|
|
3598
|
+
reason: `${c.reason} — auto-fetched`,
|
|
3599
|
+
})),
|
|
3600
|
+
];
|
|
3601
|
+
const subThemesPreview = (sub_themes && sub_themes.length > 0)
|
|
3602
|
+
? sub_themes
|
|
3603
|
+
: fallbackSubThemes(routingTopic, isHeavy);
|
|
2990
3604
|
return {
|
|
2991
3605
|
content: [{
|
|
2992
3606
|
type: 'text',
|
|
2993
|
-
text: `Deep research job started! The agent is
|
|
3607
|
+
text: `Deep research job started! The agent is performing tiered graph searches and live web synthesis. Job ID: ${jobId}\n\nserver_version: 1.35.4\nsub_themes_used:\n${JSON.stringify(subThemesPreview, null, 2)}\n\nsource_plan (sources the router selected and why):\n${JSON.stringify(sourcePlan, null, 2)}\n\nIMPORTANT: Use the check_research_status tool with Job ID ${jobId} to poll status (every 10-15s, max execution time 240s) until status is COMPLETE or FAILED.`
|
|
2994
3608
|
}]
|
|
2995
3609
|
};
|
|
2996
3610
|
}
|
|
@@ -3019,29 +3633,164 @@ sptCtx) {
|
|
|
3019
3633
|
}
|
|
3020
3634
|
if (job.status === 'COMPLETE') {
|
|
3021
3635
|
activeResearchJobs.delete(job_id); // cleanup
|
|
3022
|
-
|
|
3636
|
+
const res = typeof job.result === 'object' && job.result !== null ? job.result : { report: String(job.result), sub_themes_used: [] };
|
|
3637
|
+
const payloadText = [
|
|
3638
|
+
`sub_themes_used:\n${JSON.stringify(res.sub_themes_used || [], null, 2)}`,
|
|
3639
|
+
'',
|
|
3640
|
+
res.report
|
|
3641
|
+
].join('\n\n');
|
|
3642
|
+
return { content: [{ type: 'text', text: payloadText }] };
|
|
3023
3643
|
}
|
|
3024
3644
|
return { isError: true, content: [{ type: 'text', text: `Unknown status for job ${job_id}` }] };
|
|
3025
3645
|
});
|
|
3026
3646
|
// --- consult_analyst ---
|
|
3027
|
-
server.tool('consult_analyst', 'Consult a named Synthetic Analyst who answers in their expert voice using their curated knowledge graph.
|
|
3028
|
-
analyst_id: z.string().describe("The
|
|
3029
|
-
query: z.string().describe("The question or topic to discuss with the analyst"),
|
|
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."),
|
|
3647
|
+
server.tool('consult_analyst', 'Consult a named Synthetic Analyst expert who answers in their expert voice using their curated knowledge graph — one-off questions or multi-turn engagements (pass session_id back to continue). Synthetic analyst experts have a unique methodology, domain expertise, and analytical lens that produces insights distinct from generic search or standard graph queries. For company-specific executives (e.g. "Nike CMO", "Apple CEO", "Target CFO"), you can pass analyst_id: "brand-cmo" with company: "Nike", or pass analyst_id: "Nike CMO" directly (auto-resolves to analyst_id: "brand-cmo" and company: "Nike"). Call list_analysts first to find the right expert ID. 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.', {
|
|
3648
|
+
analyst_id: z.string().describe("The internal expert ID of the Synthetic Analyst (e.g., 'brand-cmo' or from list_analysts). Never display raw IDs or slugs, internal field names, or tool names to the user — refer to the expert by display name."),
|
|
3649
|
+
query: z.string().describe("The question or topic to discuss with the synthetic analyst"),
|
|
3650
|
+
company: z.string().optional().describe("Optional company name or stock ticker (e.g., 'Nike', 'Tesla', or 'TSLA') to bind the analyst to a specific brand context. Automatically extracted if included in analyst_id (e.g. 'Nike CMO')."),
|
|
3651
|
+
session_id: z.string().optional().describe("Pass the session_id from a previous consult response to continue that engagement — the analyst keeps context and follow-ups cost less. Omit for a one-off question."),
|
|
3031
3652
|
userId: z.string().optional().describe('Optional user identifier.')
|
|
3032
|
-
}, { title: 'Consult Synthetic Analyst', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ analyst_id, query, company, userId: uid }) => {
|
|
3653
|
+
}, { title: 'Consult Synthetic Analyst', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ analyst_id, query, company, session_id, userId: uid }) => {
|
|
3033
3654
|
try {
|
|
3655
|
+
// Resolve potential alias IDs (e.g., "Nike CMO" -> analyst_id: "brand-cmo", company: "Nike")
|
|
3656
|
+
const { analyst_id: resolvedAnalystId, company: resolvedCompany } = resolveAnalystAlias(analyst_id, company);
|
|
3657
|
+
// Proactive check: if analyst is known to be a Human Agent (Digital Twin), return referral
|
|
3658
|
+
const match = getAnalysts().find((a) => {
|
|
3659
|
+
const idKey = (a.analyst_id || a.id || a.slug || '').toLowerCase().trim();
|
|
3660
|
+
const nameKey = (a.name || '').toLowerCase().trim();
|
|
3661
|
+
const queryKey = resolvedAnalystId.toLowerCase().trim();
|
|
3662
|
+
return idKey === queryKey || nameKey === queryKey;
|
|
3663
|
+
});
|
|
3664
|
+
const isTwinMatch = match && (match.type === 'human_agent' ||
|
|
3665
|
+
match.type === 'human_twin' ||
|
|
3666
|
+
match.agent_type === 'human_twin' ||
|
|
3667
|
+
match.agent_type === 'human_agent' ||
|
|
3668
|
+
match.kind === 'human_agent' ||
|
|
3669
|
+
match.kind === 'human_twin' ||
|
|
3670
|
+
match.is_digital_twin === true ||
|
|
3671
|
+
match.is_human_agent === true);
|
|
3672
|
+
if (isTwinMatch) {
|
|
3673
|
+
const analystName = match.name || resolvedAnalystId;
|
|
3674
|
+
return {
|
|
3675
|
+
content: [{
|
|
3676
|
+
type: 'text',
|
|
3677
|
+
text: `${analystName} is a Human Agent (Digital Twin). To consult this expert, please call consult_human_agent with analyst_id "${resolvedAnalystId}". (Internal guidance: analyst_id is an internal tool parameter — refer to the expert strictly as "${analystName}" in any user-facing output; do NOT output or highlight raw technical IDs/slugs).`
|
|
3678
|
+
}]
|
|
3679
|
+
};
|
|
3680
|
+
}
|
|
3034
3681
|
// Log query to Questions table (fire-and-forget, before cache)
|
|
3035
3682
|
logUserQuery(query, 'consult_analyst');
|
|
3036
3683
|
const result = await foddaRequest('POST', `/v1/analysts/consult`, apiKey, resolveUserId(userId, uid), {
|
|
3037
|
-
analyst_id,
|
|
3684
|
+
analyst_id: resolvedAnalystId,
|
|
3038
3685
|
query,
|
|
3039
|
-
company
|
|
3686
|
+
company: resolvedCompany,
|
|
3687
|
+
session_id
|
|
3040
3688
|
});
|
|
3689
|
+
// If API indicates target is a Human Agent, return referral
|
|
3690
|
+
const isTwinResult = result?.is_human_agent ||
|
|
3691
|
+
result?.type === 'human_agent' ||
|
|
3692
|
+
result?.type === 'human_twin' ||
|
|
3693
|
+
result?.agent_type === 'human_twin' ||
|
|
3694
|
+
result?.agent_type === 'human_agent' ||
|
|
3695
|
+
result?.analyst?.agent_type === 'human_twin' ||
|
|
3696
|
+
result?.analyst?.type === 'human_agent' ||
|
|
3697
|
+
result?.analyst?.type === 'human_twin';
|
|
3698
|
+
if (isTwinResult) {
|
|
3699
|
+
const analystName = result.analyst_name || result.analyst?.name || result.name || resolvedAnalystId;
|
|
3700
|
+
return {
|
|
3701
|
+
content: [{
|
|
3702
|
+
type: 'text',
|
|
3703
|
+
text: `${analystName} is a Human Agent (Digital Twin). To consult this expert, please call consult_human_agent with analyst_id "${resolvedAnalystId}". (Internal guidance: analyst_id is an internal tool parameter — refer to the expert strictly as "${analystName}" in any user-facing output; do NOT output or highlight raw technical IDs/slugs).`
|
|
3704
|
+
}]
|
|
3705
|
+
};
|
|
3706
|
+
}
|
|
3707
|
+
const upstreamCoverage = result?.coverage;
|
|
3041
3708
|
// Extract the expert's answer text (legacy-compatible)
|
|
3042
3709
|
const reportText = typeof result.result === 'string'
|
|
3043
3710
|
? result.result
|
|
3044
3711
|
: (typeof result.report === 'string' ? result.report : JSON.stringify(result, null, 2));
|
|
3712
|
+
// 1. Capture initial raw sources returned by upstream API
|
|
3713
|
+
const rawSources = Array.isArray(result.sources_used) ? result.sources_used : [];
|
|
3714
|
+
const seenUrls = new Set();
|
|
3715
|
+
for (const s of rawSources) {
|
|
3716
|
+
if (typeof s === 'object' && s?.url) {
|
|
3717
|
+
seenUrls.add(s.url.trim());
|
|
3718
|
+
}
|
|
3719
|
+
else if (typeof s === 'string') {
|
|
3720
|
+
seenUrls.add(s.trim());
|
|
3721
|
+
}
|
|
3722
|
+
}
|
|
3723
|
+
// 2. Extract markdown links [Title](https://url) from response prose text and tag with origin: 'prose', type: 'web'
|
|
3724
|
+
const markdownLinkRegex = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
|
|
3725
|
+
const extractedSources = [];
|
|
3726
|
+
let mMatch;
|
|
3727
|
+
while ((mMatch = markdownLinkRegex.exec(reportText)) !== null) {
|
|
3728
|
+
const rawTitle = mMatch[1];
|
|
3729
|
+
const rawUrl = mMatch[2];
|
|
3730
|
+
if (rawTitle && rawUrl) {
|
|
3731
|
+
const title = rawTitle.trim();
|
|
3732
|
+
const url = rawUrl.trim();
|
|
3733
|
+
if (url && !seenUrls.has(url)) {
|
|
3734
|
+
seenUrls.add(url);
|
|
3735
|
+
extractedSources.push({ title, url, origin: 'prose', type: 'web' });
|
|
3736
|
+
}
|
|
3737
|
+
}
|
|
3738
|
+
}
|
|
3739
|
+
const mergedSources = [...rawSources, ...extractedSources];
|
|
3740
|
+
// 3. Fallback profile source if 0 sources exist
|
|
3741
|
+
if (mergedSources.length === 0) {
|
|
3742
|
+
const expertObj = result.expert || result.analyst || match || {};
|
|
3743
|
+
const expertName = expertObj.name || result.analyst_name || result.name || resolvedAnalystId;
|
|
3744
|
+
const cleanName = (expertName || '').replace(/\^\s*\[HA\]/gi, '').replace(/\^\[HA\]/g, '').trim();
|
|
3745
|
+
const rawSlug = expertObj.expertSlug || expertObj.slug || expertObj.url || expertObj.webpage_url || expertObj.analyst_id || expertObj.id || resolvedAnalystId;
|
|
3746
|
+
const expertSlug = typeof rawSlug === 'string' ? rawSlug.split('/experts/').pop()?.replace(/^https?:\/\/[^\/]+/, '').replace(/^\//, '') : resolvedAnalystId;
|
|
3747
|
+
mergedSources.push({
|
|
3748
|
+
title: `${cleanName} Human Agent — Official and Verified Digital Twin`,
|
|
3749
|
+
url: `https://www.fodda.ai/experts/${expertSlug}`,
|
|
3750
|
+
origin: 'profile',
|
|
3751
|
+
type: 'web'
|
|
3752
|
+
});
|
|
3753
|
+
}
|
|
3754
|
+
result.sources_used = mergedSources;
|
|
3755
|
+
// 4. Source Tiering & Verbatim Upstream Coverage
|
|
3756
|
+
const classifyTier = (s) => {
|
|
3757
|
+
if (typeof s === 'string') {
|
|
3758
|
+
if (s.includes('/experts/'))
|
|
3759
|
+
return 'web';
|
|
3760
|
+
if (s.includes('fodda.ai/graphs/') || s.includes('graph_id='))
|
|
3761
|
+
return 'graph';
|
|
3762
|
+
return rawSources.includes(s) ? 'graph' : 'web';
|
|
3763
|
+
}
|
|
3764
|
+
const origin = (s.origin || '').toLowerCase();
|
|
3765
|
+
const type = (s.type || s.kind || '').toLowerCase();
|
|
3766
|
+
const url = (s.url || '').toLowerCase();
|
|
3767
|
+
if (type === 'exec_quote' || origin === 'exec_quote')
|
|
3768
|
+
return 'exec_quote';
|
|
3769
|
+
if (origin === 'prose' || origin === 'profile')
|
|
3770
|
+
return 'web';
|
|
3771
|
+
if (type === 'own_graph' || type === 'library_graph' || type === 'graph' || origin === 'graph')
|
|
3772
|
+
return 'graph';
|
|
3773
|
+
if (type === 'supplemental' || type === 'financial' || type === 'sec')
|
|
3774
|
+
return 'supplemental';
|
|
3775
|
+
if (type === 'web' || origin === 'web' || url.includes('/experts/'))
|
|
3776
|
+
return 'web';
|
|
3777
|
+
if (rawSources.includes(s) && !url.includes('/experts/'))
|
|
3778
|
+
return 'graph';
|
|
3779
|
+
if (url)
|
|
3780
|
+
return 'web';
|
|
3781
|
+
return 'graph';
|
|
3782
|
+
};
|
|
3783
|
+
const execQuoteSources = result.sources_used.filter((s) => classifyTier(s) === 'exec_quote');
|
|
3784
|
+
const graphSources = result.sources_used.filter((s) => classifyTier(s) === 'graph');
|
|
3785
|
+
const suppSources = result.sources_used.filter((s) => classifyTier(s) === 'supplemental');
|
|
3786
|
+
const webSources = result.sources_used.filter((s) => classifyTier(s) === 'web');
|
|
3787
|
+
// Render explicit upstream coverage verbatim if present; fall back to graphSources check only if absent
|
|
3788
|
+
if (upstreamCoverage != null && typeof upstreamCoverage === 'string' && upstreamCoverage.trim() !== '') {
|
|
3789
|
+
result.coverage = upstreamCoverage;
|
|
3790
|
+
}
|
|
3791
|
+
else {
|
|
3792
|
+
result.coverage = (graphSources.length > 0 || execQuoteSources.length > 0) ? "FULL" : "PARTIAL";
|
|
3793
|
+
}
|
|
3045
3794
|
const parts = [reportText];
|
|
3046
3795
|
// Surface server-side timing for observability
|
|
3047
3796
|
if (result.timing_ms != null) {
|
|
@@ -3051,30 +3800,114 @@ sptCtx) {
|
|
|
3051
3800
|
if (result.coverage) {
|
|
3052
3801
|
parts.push(`\n--- COVERAGE: ${result.coverage} ---`);
|
|
3053
3802
|
}
|
|
3803
|
+
const isPartialOrThin = (result.coverage || '').toUpperCase() === 'PARTIAL' || (result.coverage || '').toLowerCase() === 'thin' || (result.coverage || '').toLowerCase() === 'out';
|
|
3804
|
+
if (isPartialOrThin && graphSources.length === 0 && execQuoteSources.length === 0) {
|
|
3805
|
+
parts.push(`--- PLATFORM NOTE (Deliver in third-person platform voice) ---\nThis Human Agent doesn't have a lot of information to respond to that request — and we didn't find a lot of new insights from the Fodda database.`);
|
|
3806
|
+
}
|
|
3054
3807
|
if (result.sources_used && Array.isArray(result.sources_used) && result.sources_used.length > 0) {
|
|
3055
|
-
const
|
|
3056
|
-
|
|
3808
|
+
const formatLine = (s) => {
|
|
3809
|
+
if (typeof s === 'string')
|
|
3810
|
+
return `- ${s}`;
|
|
3811
|
+
const name = s.title || s.label || s.name || s.id || s.slug || 'Source';
|
|
3812
|
+
return s.url ? `- ${name}: ${s.url}` : `- ${name}`;
|
|
3813
|
+
};
|
|
3814
|
+
const sourceSections = ['--- SOURCES USED ---'];
|
|
3815
|
+
if (graphSources.length > 0) {
|
|
3816
|
+
sourceSections.push(`[Graph Sources]\n${graphSources.map(formatLine).join('\n')}`);
|
|
3817
|
+
}
|
|
3818
|
+
if (execQuoteSources.length > 0) {
|
|
3819
|
+
sourceSections.push(`[Executive Quotes]\n${execQuoteSources.map(formatLine).join('\n')}`);
|
|
3820
|
+
}
|
|
3821
|
+
if (suppSources.length > 0) {
|
|
3822
|
+
sourceSections.push(`[Supplemental Data]\n${suppSources.map(formatLine).join('\n')}`);
|
|
3823
|
+
}
|
|
3824
|
+
if (webSources.length > 0) {
|
|
3825
|
+
sourceSections.push(`[Web Sources]\n${webSources.map(formatLine).join('\n')}`);
|
|
3826
|
+
}
|
|
3827
|
+
parts.push(sourceSections.join('\n\n'));
|
|
3057
3828
|
}
|
|
3058
3829
|
if (result.referrals && Array.isArray(result.referrals) && result.referrals.length > 0) {
|
|
3059
|
-
const
|
|
3060
|
-
|
|
3830
|
+
const activeAnalysts = getAnalysts();
|
|
3831
|
+
const coverageLower = (result.coverage || 'ok').toLowerCase().trim();
|
|
3832
|
+
const isFullCoverage = coverageLower === 'in' || coverageLower === 'full';
|
|
3833
|
+
const qTokens = specificQueryTokens(query);
|
|
3834
|
+
const activeReferrals = result.referrals.filter((r) => {
|
|
3835
|
+
const refId = (r.id || r.analyst_id || r.slug || r.name || '').toLowerCase().trim();
|
|
3836
|
+
const found = activeAnalysts.find((a) => {
|
|
3837
|
+
const aId = (a.analyst_id || a.id || a.slug || a.name || '').toLowerCase().trim();
|
|
3838
|
+
return aId === refId || (a.name && a.name.toLowerCase().trim() === refId);
|
|
3839
|
+
});
|
|
3840
|
+
if (found) {
|
|
3841
|
+
const st = (found.status || found.Status || '').toLowerCase().trim();
|
|
3842
|
+
if (st && st !== 'active')
|
|
3843
|
+
return false;
|
|
3844
|
+
}
|
|
3845
|
+
const rStatus = (r.status || r.Status || '').toLowerCase().trim();
|
|
3846
|
+
if (rStatus && rStatus !== 'active')
|
|
3847
|
+
return false;
|
|
3848
|
+
// Referral gating per §2.A.4: on FULL coverage, suppress unless reason shares content token with query
|
|
3849
|
+
if (isFullCoverage) {
|
|
3850
|
+
const reasonText = `${r.reason || ''} ${r.topics?.join(' ') || ''}`.toLowerCase();
|
|
3851
|
+
const sharesToken = qTokens.some(t => t.length >= 3 && reasonText.includes(t));
|
|
3852
|
+
if (!sharesToken)
|
|
3853
|
+
return false;
|
|
3854
|
+
}
|
|
3855
|
+
return true;
|
|
3856
|
+
});
|
|
3857
|
+
if (activeReferrals.length > 0) {
|
|
3858
|
+
const refLines = activeReferrals.map((r, i) => `${i + 1}. ${r.name} by ${r.curator || 'unknown'} — ${r.reason || 'related expertise'}`);
|
|
3859
|
+
parts.push(`--- REFERRALS (deliver these in 3rd person as the platform, NOT in the expert's voice) ---\n${refLines.join('\n')}`);
|
|
3860
|
+
}
|
|
3061
3861
|
}
|
|
3062
3862
|
if (result.speaker_note) {
|
|
3063
3863
|
parts.push(`--- SPEAKER NOTE: ${result.speaker_note} ---`);
|
|
3064
3864
|
}
|
|
3865
|
+
// --- Engagement continuation (Agentic Analysts Phase B) ---
|
|
3866
|
+
if (result.partial_credit_warning || result.credit_note) {
|
|
3867
|
+
parts.push(`\n> ℹ️ **Note on Deeper Fodda Graph Sweep**: ${result.partial_credit_warning || result.credit_note}`);
|
|
3868
|
+
}
|
|
3869
|
+
const analystNextMoves = generateConsultNextMoves(result, query, resolvedAnalystId || analyst_id, {
|
|
3870
|
+
currentAnalystId: resolvedAnalystId || analyst_id,
|
|
3871
|
+
knownBrand: resolvedCompany || getKnownBrand(),
|
|
3872
|
+
}, getGraphs(), getAnalysts());
|
|
3873
|
+
sessionTracker.recordNextMoves(analystNextMoves, query);
|
|
3874
|
+
const consultClosing = renderConsultClosingEnvelope(analystNextMoves);
|
|
3875
|
+
if (consultClosing.text) {
|
|
3876
|
+
parts.push(`\n\n${consultClosing.text}`);
|
|
3877
|
+
}
|
|
3065
3878
|
const consultWithheld = await settleOrWithhold({ queryTypeCode: 'expert_agent', apiKey, userId: resolveUserId(userId, uid), query }, 'consult_analyst');
|
|
3066
3879
|
if (consultWithheld)
|
|
3067
3880
|
return consultWithheld;
|
|
3068
|
-
return {
|
|
3881
|
+
return {
|
|
3882
|
+
coverage: result.coverage,
|
|
3883
|
+
next_moves: analystNextMoves,
|
|
3884
|
+
sources_used: result.sources_used,
|
|
3885
|
+
content: [{ type: 'text', text: parts.join('\n') }]
|
|
3886
|
+
};
|
|
3069
3887
|
}
|
|
3070
3888
|
catch (err) {
|
|
3071
3889
|
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
3072
3890
|
if (trialResult)
|
|
3073
3891
|
return trialResult;
|
|
3892
|
+
const errData = err.response?.data;
|
|
3893
|
+
const isTwinError = errData?.is_human_agent ||
|
|
3894
|
+
errData?.error?.is_human_agent ||
|
|
3895
|
+
errData?.agent_type === 'human_twin' ||
|
|
3896
|
+
errData?.type === 'human_twin' ||
|
|
3897
|
+
errData?.type === 'human_agent' ||
|
|
3898
|
+
errData?.error?.agent_type === 'human_twin';
|
|
3899
|
+
if (isTwinError) {
|
|
3900
|
+
return {
|
|
3901
|
+
content: [{
|
|
3902
|
+
type: 'text',
|
|
3903
|
+
text: `${analyst_id} is a Human Agent (Digital Twin). To consult this expert, please call consult_human_agent with analyst_id "${analyst_id}". (Internal guidance: analyst_id is an internal tool parameter — refer to the expert by display name in any user-facing output; do NOT output or highlight raw technical IDs/slugs).`
|
|
3904
|
+
}]
|
|
3905
|
+
};
|
|
3906
|
+
}
|
|
3074
3907
|
// Surface timeout explicitly so clients get actionable guidance
|
|
3075
3908
|
if (err.code === 'ECONNABORTED' || err.message?.includes('timeout')) {
|
|
3076
3909
|
return { isError: true, content: [{ type: 'text', text: JSON.stringify({
|
|
3077
|
-
error: `Analyst consultation timed out (
|
|
3910
|
+
error: `Analyst consultation timed out (90s). 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
3911
|
analyst_id,
|
|
3079
3912
|
timeout: true
|
|
3080
3913
|
}) }] };
|
|
@@ -3083,6 +3916,487 @@ sptCtx) {
|
|
|
3083
3916
|
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
|
|
3084
3917
|
}
|
|
3085
3918
|
});
|
|
3919
|
+
// --- consult_human_agent ---
|
|
3920
|
+
server.tool('consult_human_agent', 'Consult an authorized Human Agent (Digital Twin) expert created directly with the named expert\'s consent, participation, and curated knowledge graph. The expert answers in their voice — one-off questions or multi-turn engagements (pass session_id back to continue). Each human agent has a unique methodology, domain expertise, and analytical lens distinct from generic search or standard graph queries. Call list_analysts first to find the right expert ID. 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. Response may include `book_a_call` (URL + a pre-written booking sentence shown verbatim) for booking time with the real person — surface it when the user wants to hire or speak to the expert.', {
|
|
3921
|
+
analyst_id: z.string().describe("The internal expert ID of the Human Agent (from list_analysts). Never display raw IDs or slugs, internal field names, or tool names to the user — refer to the expert by display name."),
|
|
3922
|
+
query: z.string().describe("The question or topic to discuss with the human agent"),
|
|
3923
|
+
company: z.string().optional().describe("Optional company name or stock ticker (e.g., 'Nike', 'Tesla', or 'TSLA') to bind the human agent to a specific brand context."),
|
|
3924
|
+
session_id: z.string().optional().describe("Pass the session_id from a previous consult response to continue that engagement — the human agent keeps context and follow-ups cost less. Omit for a one-off question."),
|
|
3925
|
+
userId: z.string().optional().describe('Optional user identifier.')
|
|
3926
|
+
}, { title: 'Consult Human Agent (Digital Twin)', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ analyst_id, query, company, session_id, userId: uid }) => {
|
|
3927
|
+
try {
|
|
3928
|
+
const { analyst_id: resolvedAnalystId, company: resolvedCompany } = resolveAnalystAlias(analyst_id, company);
|
|
3929
|
+
const match = getAnalysts().find((a) => {
|
|
3930
|
+
const idKey = (a.analyst_id || a.id || a.slug || '').toLowerCase().trim();
|
|
3931
|
+
const nameKey = (a.name || '').toLowerCase().trim();
|
|
3932
|
+
const queryKey = resolvedAnalystId.toLowerCase().trim();
|
|
3933
|
+
return idKey === queryKey || nameKey === queryKey;
|
|
3934
|
+
});
|
|
3935
|
+
logUserQuery(query, 'consult_human_agent');
|
|
3936
|
+
const result = await foddaRequest('POST', `/v1/human-agents/consult`, apiKey, resolveUserId(userId, uid), {
|
|
3937
|
+
analyst_id: resolvedAnalystId,
|
|
3938
|
+
query,
|
|
3939
|
+
company: resolvedCompany,
|
|
3940
|
+
session_id
|
|
3941
|
+
});
|
|
3942
|
+
const upstreamCoverage = result?.coverage;
|
|
3943
|
+
const reportText = typeof result.result === 'string'
|
|
3944
|
+
? result.result
|
|
3945
|
+
: (typeof result.report === 'string' ? result.report : (typeof result.response === 'string' ? result.response : JSON.stringify(result, null, 2)));
|
|
3946
|
+
// 1. Capture initial raw sources returned by upstream API
|
|
3947
|
+
const rawSources = Array.isArray(result.sources_used) ? result.sources_used : [];
|
|
3948
|
+
const seenUrls = new Set();
|
|
3949
|
+
for (const s of rawSources) {
|
|
3950
|
+
if (typeof s === 'object' && s?.url) {
|
|
3951
|
+
seenUrls.add(s.url.trim());
|
|
3952
|
+
}
|
|
3953
|
+
else if (typeof s === 'string') {
|
|
3954
|
+
seenUrls.add(s.trim());
|
|
3955
|
+
}
|
|
3956
|
+
}
|
|
3957
|
+
// 2. Extract markdown links [Title](https://url) from response prose text and tag with origin: 'prose', type: 'web'
|
|
3958
|
+
const markdownLinkRegex = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
|
|
3959
|
+
const extractedSources = [];
|
|
3960
|
+
let mMatch;
|
|
3961
|
+
while ((mMatch = markdownLinkRegex.exec(reportText)) !== null) {
|
|
3962
|
+
const rawTitle = mMatch[1];
|
|
3963
|
+
const rawUrl = mMatch[2];
|
|
3964
|
+
if (rawTitle && rawUrl) {
|
|
3965
|
+
const title = rawTitle.trim();
|
|
3966
|
+
const url = rawUrl.trim();
|
|
3967
|
+
if (url && !seenUrls.has(url)) {
|
|
3968
|
+
seenUrls.add(url);
|
|
3969
|
+
extractedSources.push({ title, url, origin: 'prose', type: 'web' });
|
|
3970
|
+
}
|
|
3971
|
+
}
|
|
3972
|
+
}
|
|
3973
|
+
const mergedSources = [...rawSources, ...extractedSources];
|
|
3974
|
+
// 3. Fallback profile source if 0 sources exist
|
|
3975
|
+
if (mergedSources.length === 0) {
|
|
3976
|
+
const expertObj = result.expert || result.analyst || match || {};
|
|
3977
|
+
const expertName = expertObj.name || result.analyst_name || result.name || resolvedAnalystId;
|
|
3978
|
+
const cleanName = (expertName || '').replace(/\^\s*\[HA\]/gi, '').replace(/\^\[HA\]/g, '').trim();
|
|
3979
|
+
const rawSlug = expertObj.expertSlug || expertObj.slug || expertObj.url || expertObj.webpage_url || expertObj.analyst_id || expertObj.id || resolvedAnalystId;
|
|
3980
|
+
const expertSlug = typeof rawSlug === 'string' ? rawSlug.split('/experts/').pop()?.replace(/^https?:\/\/[^\/]+/, '').replace(/^\//, '') : resolvedAnalystId;
|
|
3981
|
+
mergedSources.push({
|
|
3982
|
+
title: `${cleanName} Human Agent — Official and Verified Digital Twin`,
|
|
3983
|
+
url: `https://www.fodda.ai/experts/${expertSlug}`,
|
|
3984
|
+
origin: 'profile',
|
|
3985
|
+
type: 'web'
|
|
3986
|
+
});
|
|
3987
|
+
}
|
|
3988
|
+
result.sources_used = mergedSources;
|
|
3989
|
+
// 4. Source Tiering & Verbatim Upstream Coverage
|
|
3990
|
+
const classifyTier = (s) => {
|
|
3991
|
+
if (typeof s === 'string') {
|
|
3992
|
+
if (s.includes('/experts/'))
|
|
3993
|
+
return 'web';
|
|
3994
|
+
if (s.includes('fodda.ai/graphs/') || s.includes('graph_id='))
|
|
3995
|
+
return 'graph';
|
|
3996
|
+
return rawSources.includes(s) ? 'graph' : 'web';
|
|
3997
|
+
}
|
|
3998
|
+
const origin = (s.origin || '').toLowerCase();
|
|
3999
|
+
const type = (s.type || s.kind || '').toLowerCase();
|
|
4000
|
+
const url = (s.url || '').toLowerCase();
|
|
4001
|
+
if (type === 'exec_quote' || origin === 'exec_quote')
|
|
4002
|
+
return 'exec_quote';
|
|
4003
|
+
if (origin === 'prose' || origin === 'profile')
|
|
4004
|
+
return 'web';
|
|
4005
|
+
if (type === 'own_graph' || type === 'library_graph' || type === 'graph' || origin === 'graph')
|
|
4006
|
+
return 'graph';
|
|
4007
|
+
if (type === 'supplemental' || type === 'financial' || type === 'sec')
|
|
4008
|
+
return 'supplemental';
|
|
4009
|
+
if (type === 'web' || origin === 'web' || url.includes('/experts/'))
|
|
4010
|
+
return 'web';
|
|
4011
|
+
if (rawSources.includes(s) && !url.includes('/experts/'))
|
|
4012
|
+
return 'graph';
|
|
4013
|
+
if (url)
|
|
4014
|
+
return 'web';
|
|
4015
|
+
return 'graph';
|
|
4016
|
+
};
|
|
4017
|
+
const execQuoteSources = result.sources_used.filter((s) => classifyTier(s) === 'exec_quote');
|
|
4018
|
+
const graphSources = result.sources_used.filter((s) => classifyTier(s) === 'graph');
|
|
4019
|
+
const suppSources = result.sources_used.filter((s) => classifyTier(s) === 'supplemental');
|
|
4020
|
+
const webSources = result.sources_used.filter((s) => classifyTier(s) === 'web');
|
|
4021
|
+
// Render explicit upstream coverage verbatim if present; fall back to graphSources check only if absent
|
|
4022
|
+
if (upstreamCoverage != null && typeof upstreamCoverage === 'string' && upstreamCoverage.trim() !== '') {
|
|
4023
|
+
result.coverage = upstreamCoverage;
|
|
4024
|
+
}
|
|
4025
|
+
else {
|
|
4026
|
+
result.coverage = (graphSources.length > 0 || execQuoteSources.length > 0) ? "FULL" : "PARTIAL";
|
|
4027
|
+
}
|
|
4028
|
+
const parts = [reportText];
|
|
4029
|
+
if (result.timing_ms != null) {
|
|
4030
|
+
parts.push(`\n--- TIMING: ${result.timing_ms}ms server-side ---`);
|
|
4031
|
+
}
|
|
4032
|
+
if (result.coverage) {
|
|
4033
|
+
parts.push(`\n--- COVERAGE: ${result.coverage} ---`);
|
|
4034
|
+
}
|
|
4035
|
+
const isPartialOrThin = (result.coverage || '').toUpperCase() === 'PARTIAL' || (result.coverage || '').toLowerCase() === 'thin' || (result.coverage || '').toLowerCase() === 'out';
|
|
4036
|
+
if (isPartialOrThin && graphSources.length === 0 && execQuoteSources.length === 0) {
|
|
4037
|
+
parts.push(`--- PLATFORM NOTE (Deliver in third-person platform voice) ---\nThis Human Agent doesn't have a lot of information to respond to that request — and we didn't find a lot of new insights from the Fodda database.`);
|
|
4038
|
+
}
|
|
4039
|
+
if (result.sources_used && Array.isArray(result.sources_used) && result.sources_used.length > 0) {
|
|
4040
|
+
const formatLine = (s) => {
|
|
4041
|
+
if (typeof s === 'string')
|
|
4042
|
+
return `- ${s}`;
|
|
4043
|
+
const name = s.title || s.label || s.name || s.id || s.slug || 'Source';
|
|
4044
|
+
return s.url ? `- ${name}: ${s.url}` : `- ${name}`;
|
|
4045
|
+
};
|
|
4046
|
+
const sourceSections = ['--- SOURCES USED ---'];
|
|
4047
|
+
if (graphSources.length > 0) {
|
|
4048
|
+
sourceSections.push(`[Graph Sources]\n${graphSources.map(formatLine).join('\n')}`);
|
|
4049
|
+
}
|
|
4050
|
+
if (execQuoteSources.length > 0) {
|
|
4051
|
+
sourceSections.push(`[Executive Quotes]\n${execQuoteSources.map(formatLine).join('\n')}`);
|
|
4052
|
+
}
|
|
4053
|
+
if (suppSources.length > 0) {
|
|
4054
|
+
sourceSections.push(`[Supplemental Data]\n${suppSources.map(formatLine).join('\n')}`);
|
|
4055
|
+
}
|
|
4056
|
+
if (webSources.length > 0) {
|
|
4057
|
+
sourceSections.push(`[Web Sources]\n${webSources.map(formatLine).join('\n')}`);
|
|
4058
|
+
}
|
|
4059
|
+
parts.push(sourceSections.join('\n\n'));
|
|
4060
|
+
}
|
|
4061
|
+
if (result.referrals && Array.isArray(result.referrals) && result.referrals.length > 0) {
|
|
4062
|
+
const activeAnalysts = getAnalysts();
|
|
4063
|
+
const coverageLower = (result.coverage || 'ok').toLowerCase().trim();
|
|
4064
|
+
const isFullCoverage = coverageLower === 'in' || coverageLower === 'full';
|
|
4065
|
+
const qTokens = specificQueryTokens(query);
|
|
4066
|
+
const activeReferrals = result.referrals.filter((r) => {
|
|
4067
|
+
const refId = (r.id || r.analyst_id || r.slug || r.name || '').toLowerCase().trim();
|
|
4068
|
+
const found = activeAnalysts.find((a) => {
|
|
4069
|
+
const aId = (a.analyst_id || a.id || a.slug || a.name || '').toLowerCase().trim();
|
|
4070
|
+
return aId === refId || (a.name && a.name.toLowerCase().trim() === refId);
|
|
4071
|
+
});
|
|
4072
|
+
if (found) {
|
|
4073
|
+
const st = (found.status || found.Status || '').toLowerCase().trim();
|
|
4074
|
+
if (st && st !== 'active')
|
|
4075
|
+
return false;
|
|
4076
|
+
}
|
|
4077
|
+
const rStatus = (r.status || r.Status || '').toLowerCase().trim();
|
|
4078
|
+
if (rStatus && rStatus !== 'active')
|
|
4079
|
+
return false;
|
|
4080
|
+
// Referral gating per §2.A.4: on FULL coverage, suppress unless reason shares content token with query
|
|
4081
|
+
if (isFullCoverage) {
|
|
4082
|
+
const reasonText = `${r.reason || ''} ${r.topics?.join(' ') || ''}`.toLowerCase();
|
|
4083
|
+
const sharesToken = qTokens.some(t => t.length >= 3 && reasonText.includes(t));
|
|
4084
|
+
if (!sharesToken)
|
|
4085
|
+
return false;
|
|
4086
|
+
}
|
|
4087
|
+
return true;
|
|
4088
|
+
});
|
|
4089
|
+
if (activeReferrals.length > 0) {
|
|
4090
|
+
const refLines = activeReferrals.map((r, i) => `${i + 1}. ${r.name} by ${r.curator || 'unknown'} — ${r.reason || 'related expertise'}`);
|
|
4091
|
+
parts.push(`--- REFERRALS (deliver these in 3rd person as the platform, NOT in the expert's voice) ---\n${refLines.join('\n')}`);
|
|
4092
|
+
}
|
|
4093
|
+
}
|
|
4094
|
+
if (result.speaker_note) {
|
|
4095
|
+
parts.push(`--- SPEAKER NOTE: ${result.speaker_note} ---`);
|
|
4096
|
+
}
|
|
4097
|
+
if (result.book_a_call) {
|
|
4098
|
+
const callText = result.book_a_call.rate_display
|
|
4099
|
+
? `${result.book_a_call.rate_display} — ${result.book_a_call.url}`
|
|
4100
|
+
: (result.book_a_call.url || '');
|
|
4101
|
+
parts.push(`--- BOOK A CALL: ${callText} ---`);
|
|
4102
|
+
}
|
|
4103
|
+
if (result.partial_credit_warning || result.credit_note) {
|
|
4104
|
+
parts.push(`\n> ℹ️ **Note on Deeper Fodda Graph Sweep**: ${result.partial_credit_warning || result.credit_note}`);
|
|
4105
|
+
}
|
|
4106
|
+
const humanAgentNextMoves = generateConsultNextMoves(result, query, resolvedAnalystId || analyst_id, {
|
|
4107
|
+
currentAnalystId: resolvedAnalystId || analyst_id,
|
|
4108
|
+
knownBrand: resolvedCompany || getKnownBrand(),
|
|
4109
|
+
}, getGraphs(), getAnalysts());
|
|
4110
|
+
sessionTracker.recordNextMoves(humanAgentNextMoves, query);
|
|
4111
|
+
const consultClosing = renderConsultClosingEnvelope(humanAgentNextMoves);
|
|
4112
|
+
if (consultClosing.text) {
|
|
4113
|
+
parts.push(`\n\n${consultClosing.text}`);
|
|
4114
|
+
}
|
|
4115
|
+
const consultWithheld = await settleOrWithhold({ queryTypeCode: 'human_agent_consult', apiKey, userId: resolveUserId(userId, uid), query }, 'consult_human_agent');
|
|
4116
|
+
if (consultWithheld)
|
|
4117
|
+
return consultWithheld;
|
|
4118
|
+
return {
|
|
4119
|
+
coverage: result.coverage,
|
|
4120
|
+
next_moves: humanAgentNextMoves,
|
|
4121
|
+
sources_used: result.sources_used,
|
|
4122
|
+
...(result.analyst ? { analyst: result.analyst } : {}),
|
|
4123
|
+
book_a_call: result.book_a_call ?? null,
|
|
4124
|
+
content: [{ type: 'text', text: parts.join('\n') }]
|
|
4125
|
+
};
|
|
4126
|
+
}
|
|
4127
|
+
catch (err) {
|
|
4128
|
+
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
4129
|
+
if (trialResult)
|
|
4130
|
+
return trialResult;
|
|
4131
|
+
if (err.code === 'ECONNABORTED' || err.message?.includes('timeout')) {
|
|
4132
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({
|
|
4133
|
+
error: `Human Agent consultation timed out (90s). 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.`,
|
|
4134
|
+
analyst_id,
|
|
4135
|
+
timeout: true
|
|
4136
|
+
}) }] };
|
|
4137
|
+
}
|
|
4138
|
+
const msg = err.response?.data?.error?.message || err.response?.data?.message || err.message;
|
|
4139
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
|
|
4140
|
+
}
|
|
4141
|
+
});
|
|
4142
|
+
// --- request_deliverable (Agentic Analysts Phase C) ---
|
|
4143
|
+
server.tool('request_deliverable', 'Commission a finished document from an analyst — a skill-based deliverable like a marketing plan, deck review, or trend briefing. Specify offering_key (see the `offerings` list on each analyst from list_analysts), a brief (2–5 sentences: audience, goal, constraints), and optional attachments. The analyst researches on your behalf, then produces the document in the background. Returns a job_id — poll with check_deliverable_status until status is "completed" to get the artifact links. The offering price is charged on acceptance; the analyst\'s research is included, not billed separately. Example brief: "Marketing plan for a DTC skincare launch targeting Gen-Z, $50k budget, 90-day horizon."', {
|
|
4144
|
+
analyst_id: z.string().describe("The internal analyst ID producing the deliverable (from list_analysts). Never display raw IDs or slugs, internal field names, or tool names to the user — refer to the expert by display name."),
|
|
4145
|
+
offering_key: z.string().describe("The offering to commission (e.g., 'marketing_plan'). See the `offerings` array on each analyst from list_analysts."),
|
|
4146
|
+
brief: z.string().describe("2–5 sentences: audience, goal, constraints. Agents imitate the example in the tool description — be concrete."),
|
|
4147
|
+
attachments: z.array(z.object({ content: z.string() })).optional().describe("Optional supporting text files mounted into the analyst's workspace (max 5)."),
|
|
4148
|
+
userId: z.string().optional().describe('Optional user identifier.')
|
|
4149
|
+
}, { title: 'Request Analyst Deliverable', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ analyst_id, offering_key, brief, attachments, userId: uid }) => {
|
|
4150
|
+
try {
|
|
4151
|
+
logUserQuery(brief, 'skill_deliverable');
|
|
4152
|
+
// Billing is server-side in POST /deliver (the offering's published
|
|
4153
|
+
// price is charged up-front there — authoritative, and the meter
|
|
4154
|
+
// path can't express a per-offering price). The MCP does not settle
|
|
4155
|
+
// here, so there's no double-charge.
|
|
4156
|
+
const result = await foddaRequest('POST', `/v1/human-agents/${encodeURIComponent(analyst_id)}/deliver`, apiKey, resolveUserId(userId, uid), { offering_key, brief, attachments });
|
|
4157
|
+
const lines = [
|
|
4158
|
+
`Deliverable commissioned from ${result?.offering?.name || offering_key}.`,
|
|
4159
|
+
`Job ID: ${result?.job_id}`,
|
|
4160
|
+
`Status: ${result?.status || 'working'}`,
|
|
4161
|
+
result?.price_usd != null ? `Price: $${result.price_usd}` : '',
|
|
4162
|
+
`The analyst is producing this in the background. Poll with check_deliverable_status(job_id: "${result?.job_id}") until status is "completed", then present the artifact links.`,
|
|
4163
|
+
].filter(Boolean);
|
|
4164
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
4165
|
+
}
|
|
4166
|
+
catch (err) {
|
|
4167
|
+
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
4168
|
+
if (trialResult)
|
|
4169
|
+
return trialResult;
|
|
4170
|
+
const msg = err.response?.data?.error?.message || err.response?.data?.message || err.message;
|
|
4171
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
|
|
4172
|
+
}
|
|
4173
|
+
});
|
|
4174
|
+
// --- check_deliverable_status (Agentic Analysts Phase C) ---
|
|
4175
|
+
server.tool('check_deliverable_status', 'Poll a deliverable commissioned with request_deliverable. Pass the job_id from that response. Returns the current status ("working" | "completed" | "failed") and, once completed, the artifact links to present to the user. Polling is free. Deliverables typically take a few minutes — poll every ~15–30s.', {
|
|
4176
|
+
job_id: z.string().describe("The job_id returned by request_deliverable."),
|
|
4177
|
+
userId: z.string().optional().describe('Optional user identifier.')
|
|
4178
|
+
}, { title: 'Check Deliverable Status', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ job_id, userId: uid }) => {
|
|
4179
|
+
try {
|
|
4180
|
+
const result = await foddaRequest('GET', `/v1/human-agents/deliverables/${encodeURIComponent(job_id)}`, apiKey, resolveUserId(userId, uid));
|
|
4181
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
4182
|
+
}
|
|
4183
|
+
catch (err) {
|
|
4184
|
+
const trialResult = await handleTrialCreditExhaustion(err, apiKey, userId);
|
|
4185
|
+
if (trialResult)
|
|
4186
|
+
return trialResult;
|
|
4187
|
+
const msg = err.response?.data?.error?.message || err.response?.data?.message || err.message;
|
|
4188
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
|
|
4189
|
+
}
|
|
4190
|
+
});
|
|
4191
|
+
// --- Expert Onboarding (Connector Flow) ---
|
|
4192
|
+
const parseWebsiteError = (err) => {
|
|
4193
|
+
const d = err.response?.data;
|
|
4194
|
+
return (typeof d?.error === 'string' ? d.error : d?.error?.message) || d?.message || err.message;
|
|
4195
|
+
};
|
|
4196
|
+
server.tool('begin_expert_onboarding', 'Begin the Fodda expert onboarding process. Checks for connected Fodda credentials and provides the direct sign-in link (https://www.fodda.ai/join-experts) if unlinked. Instruct the agent/LLM to manage expectations immediately. When asking the expert for their topic focus first and then their recency window, explicitly explain the flow: "Second, here\'s the flow: you provide answers in this chat session, then we\'ll run a background research project on your public work, then we run an AI probe of your expertise and reasoning style, and finally schedule a 15–20 minute audio interview to explore your forward-looking predictions, contrarian views, and practical problem-solving — filling the gaps that chat history alone cannot capture. You\'ll get to review everything before anything is submitted." When indexing or analyzing expert data, reassure them: "And remember, nothing gets sent to the Fodda servers without your sign off." Do not announce "I\'ll read back through your conversation history" until you actually begin the analysis. Display the expertise map (Prompt B) before the voice study (Prompt A). When presenting the expertise map JSON to the expert, label it "Expertise Review - Step 1" and introduce it with: "This JSON file contains our analysis of your expertise - and is a file format that we use to help AI get to answers quicker. The details reflect the themes above. If you are not happy about something I have included, just provide the feedback and I will run the exercise again to update the document." Never use the title "Iteration Feedback Invitation". Prepend the briefs with: "We\'ll use these to set up your Human Agent." After showing the briefs, you MUST present both consent links inline BEFORE asking for acceptance: Terms of Service (https://www.fodda.ai/terms) and Privacy Policy (https://www.fodda.ai/privacy). Only after the expert has been shown these links, tell them exactly what to type: Reply "reviewed - I accept the terms". Never solicit or record consent before the links have been shown in the conversation.\n\n[INTERACTIVE PROMPTS - REQUIRED IF SUPPORTED]\nDo not ask for topic focus and recency window as open prose questions. If your client exposes an interactive selection or option-chip tool, you MUST use it to present both questions as tappable options, asked in a single pass rather than sequentially.\nQ1 - Topic focus (single select, max 4 options): derive 3 candidate focus areas from what you already know of this expert\'s work, plus a final option "Analyze broadly across all of it". Options must be specific to this expert. Never use generic placeholder categories.\nQ2 - Recency window (single select): "90 days", "120 days (default)", "12 months", "All time". Do not state or output any framing about older material being demoted to legacy canon.\nQ3 - Preferred 1-hour consultation rate (single select): "If a Fodda client wishes to book a 1-on-1 video call with you, what is your preferred hourly fee? (Options: No Calls, $250/hr, $500/hr, $750/hr, $1,000/hr, $2,000/hr)". Record this value under callPrice in submit_basic_info.\nIf no interactive tool is available, fall back to a numbered list of the same options and ask the expert to reply with numbers.\n\n[FLOW VISUALIZATION - REQUIRED IF SUPPORTED]\nBefore asking the two questions, show the expert the full onboarding path. If your client has a diagram, SVG, or HTML rendering tool, render it as a horizontal stepper with the current stage marked "You are here". Use Fodda\'s brand accent color #663399 for the active stage, highlights, and connectors. The "You are here" label and Stage 5 theme cards MUST stay legible on any page background (this often renders on a dark/black page): give the "You are here" label a solid #663399 fill with #ffffff text - do NOT use a pale #f5f0ff pill with light-grey text, which vanishes on dark backgrounds. FOR THEME-SELECTION CARDS (STAGE 5): Never pair a hard-coded light fill (#f5f0ff) with theme-inherited foreground text (which flips to white/near-white in dark mode). Either (1) use native client surface and text tokens for card backgrounds and body text so light/dark mode auto-adjusts, reserving #663399 for borders, checkboxes, and active dots; or (2) if prescribing exact hexes, ALWAYS pin foreground and background together — a #f5f0ff fill MUST carry dark-purple text (#3C3489 or #26215C), never inherited or theme-default text.\nStages: 1. Focus and window -> 2. Background research on your public work -> 3. Expertise map and voice study (you review) -> 4. Terms and consent -> 5. Choose your themes -> 6. Expertise Deep-Dive (Audio) -> Human Agent live.\nRestate the stepper at the start of each subsequent stage so the expert always knows where they are and what remains. If no rendering tool is available, output it as a text ladder with a marker on the current stage.', {
|
|
4197
|
+
userId: z.string().optional().describe('Optional user identifier.')
|
|
4198
|
+
}, { title: 'Kick off your Fodda Human Agent onboarding', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ userId: uid }) => {
|
|
4199
|
+
if (!apiKey) {
|
|
4200
|
+
return {
|
|
4201
|
+
content: [{
|
|
4202
|
+
type: 'text',
|
|
4203
|
+
text: 'Welcome to Fodda Human Agent Onboarding!\n\nTo build your digital twin directly inside Claude, your Fodda account needs to be connected.\n\n👉 **Next Step:** Please visit https://www.fodda.ai/join-experts to link your account or sign in. Once linked, reply "continue" and we will kick off your background research and voice study.'
|
|
4204
|
+
}]
|
|
4205
|
+
};
|
|
4206
|
+
}
|
|
4207
|
+
try {
|
|
4208
|
+
const userEmail = resolveUserId(userId, uid);
|
|
4209
|
+
const result = await foddaRequest('GET', '/api/onboarding-prompts', apiKey, userEmail);
|
|
4210
|
+
const sanitizeOnboardingPrompts = (text) => {
|
|
4211
|
+
return text
|
|
4212
|
+
.replace(/On the recency window:\s*older material isn't thrown away[^\n"\\]*/gi, '')
|
|
4213
|
+
.replace(/Anything outside the window gets demoted to legacy canon[^\n"\\]*/gi, '')
|
|
4214
|
+
.replace(/State in the framing line that older material is demoted to legacy canon[^\n"\\]*/gi, '')
|
|
4215
|
+
.replace(/With this information we'll run a background research project on your public work, run an AI probe of your expertise and tone of voice, and later run a short AI audio interview\. You'll get to review everything before anything is submitted\./gi, "Second, here's the flow: you provide answers in this chat session, then we'll run a background research project on your public work, then we run an AI probe of your expertise and reasoning style, and finally schedule a 15–20 minute audio interview to explore your forward-looking predictions, contrarian views, and practical problem-solving — filling the gaps that chat history alone cannot capture. You'll get to review everything before anything is submitted.")
|
|
4216
|
+
.replace(/then we'll run a background research project on your public work, then we run an AI probe of your expertise and tone of voice, and later run a short AI audio interview\. You'll get to review everything before anything is submitted\./gi, "then we'll run a background research project on your public work, then we run an AI probe of your expertise and reasoning style, and finally schedule a 15–20 minute audio interview to explore your forward-looking predictions, contrarian views, and practical problem-solving — filling the gaps that chat history alone cannot capture. You'll get to review everything before anything is submitted.")
|
|
4217
|
+
.replace(/Iteration Feedback Invitation/gi, "Expertise Review - Step 1")
|
|
4218
|
+
.replace(/Please review this summary\. If you are not happy about something I have included, just provide the feedback and I will run the exercise again to update the document\./gi, "This JSON file contains our analysis of your expertise - and is a file format that we use to help AI get to answers quicker. The details reflect the themes above. If you are not happy about something I have included, just provide the feedback and I will run the exercise again to update the document.");
|
|
4219
|
+
};
|
|
4220
|
+
if (result.alreadyActive) {
|
|
4221
|
+
return { content: [{ type: 'text', text: sanitizeOnboardingPrompts(result.message || '') }] };
|
|
4222
|
+
}
|
|
4223
|
+
const identityWarning = `[IDENTITY WARNING]\nFirst, identity: I'll register this profile under **${userEmail}**. If you want it tied to a different account, stop here and re-provision at https://www.fodda.ai/join-experts. Otherwise we're good.\nSecond, here's the flow: you provide answers in this chat session, then we'll run a background research project on your public work, then we run an AI probe of your expertise and reasoning style, and finally schedule a 15–20 minute audio interview to explore your forward-looking predictions, contrarian views, and practical problem-solving — filling the gaps that chat history alone cannot capture. You'll get to review everything before anything is submitted.\n\n`;
|
|
4224
|
+
const cleanedResult = sanitizeOnboardingPrompts(JSON.stringify(result, null, 2));
|
|
4225
|
+
return { content: [{ type: 'text', text: identityWarning + cleanedResult }] };
|
|
4226
|
+
}
|
|
4227
|
+
catch (err) {
|
|
4228
|
+
return { isError: true, content: [{ type: 'text', text: parseWebsiteError(err) }] };
|
|
4229
|
+
}
|
|
4230
|
+
});
|
|
4231
|
+
server.tool('submit_basic_info', 'Submit basic information (name, role, knowledge area, call price) for the expert onboarding process. IMPORTANT: Never display analystId, internal schema fields, or developer meta-instructions to the expert. Inform the expert: "Background research on your public work is now running. It folds into the profile automatically, so we can keep moving." Reassure them: "And remember, nothing gets sent to the Fodda servers without your sign off." Render the visual horizontal flow stepper for stage 2 ("Background research"). Next step: Run expert_onboarding_research tool.', {
|
|
4232
|
+
name: z.string().describe("The expert's full name"),
|
|
4233
|
+
role: z.string().describe("The expert's current role or title"),
|
|
4234
|
+
knowledgeArea: z.string().describe("The expert's primary knowledge area"),
|
|
4235
|
+
callPrice: z.string().optional().describe("The expert's preferred 1-hour video/telephone consultation rate: 'No Calls', '$250/hr', '$500/hr', '$750/hr', '$1,000/hr', or '$2,000/hr'. Recorded under callPrice."),
|
|
4236
|
+
userId: z.string().optional().describe('Optional user identifier.')
|
|
4237
|
+
}, { title: 'the expert registration step', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ name, role, knowledgeArea, callPrice, userId: uid }) => {
|
|
4238
|
+
if (!apiKey) {
|
|
4239
|
+
return { content: [{ type: 'text', text: 'Your Fodda credentials are missing. Add Fodda as a connector to begin (or continue) onboarding: https://www.fodda.ai/join-experts' }] };
|
|
4240
|
+
}
|
|
4241
|
+
try {
|
|
4242
|
+
const result = await foddaRequest('POST', '/api/prepare-voice-interview', apiKey, resolveUserId(userId, uid), { action: 'basic_info', name, role, knowledgeArea, callPrice });
|
|
4243
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
4244
|
+
}
|
|
4245
|
+
catch (err) {
|
|
4246
|
+
return { isError: true, content: [{ type: 'text', text: parseWebsiteError(err) }] };
|
|
4247
|
+
}
|
|
4248
|
+
});
|
|
4249
|
+
server.tool('expert_onboarding_research', 'Kick off asynchronous background research on your public work for the expert onboarding. The identity is derived from your connector session. When beginning the analysis for stage 3 (indexing/reading back through conversations and meeting transcripts), explicitly reassure the expert: "And remember, nothing gets sent to the Fodda servers without your sign off." Render the visual horizontal flow stepper for stage 3 ("Expertise map and voice study"). Never expose developer instructions or raw JSON keys to the expert.', {
|
|
4250
|
+
userId: z.string().optional().describe('Optional user identifier.')
|
|
4251
|
+
}, { title: 'the background research step', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ userId: uid }) => {
|
|
4252
|
+
if (!apiKey) {
|
|
4253
|
+
return { content: [{ type: 'text', text: 'Your Fodda credentials are missing. Add Fodda as a connector to begin (or continue) onboarding: https://www.fodda.ai/join-experts' }] };
|
|
4254
|
+
}
|
|
4255
|
+
try {
|
|
4256
|
+
const result = await foddaRequest('POST', '/api/deep-research', apiKey, resolveUserId(userId, uid));
|
|
4257
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
4258
|
+
}
|
|
4259
|
+
catch (err) {
|
|
4260
|
+
return { isError: true, content: [{ type: 'text', text: parseWebsiteError(err) }] };
|
|
4261
|
+
}
|
|
4262
|
+
});
|
|
4263
|
+
server.tool('submit_expertise_analysis', 'Submit the analyzed voice study and expertise map JSON outputs from the LLM. Once submitted, render the visual horizontal flow stepper for stage 4/5 ("Terms and consent" -> "Choose your themes"), present the Expertise Review - Step 1, and call get_detected_themes. Never display analystId or developer meta-instructions to the expert.', {
|
|
4264
|
+
voiceStudy: z.string().describe("JSON string of the voice study"),
|
|
4265
|
+
expertTopics: z.string().describe("JSON string of the expertise topics"),
|
|
4266
|
+
termsAccepted: z.boolean().describe("Must be true. The expert must explicitly accept the Fodda Terms of Service (https://www.fodda.ai/terms) and Privacy Policy (https://www.fodda.ai/privacy) after you present the links to them."),
|
|
4267
|
+
userId: z.string().optional().describe('Optional user identifier.')
|
|
4268
|
+
}, { title: 'the expertise analysis submission step', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ voiceStudy, expertTopics, termsAccepted, userId: uid }) => {
|
|
4269
|
+
if (!termsAccepted) {
|
|
4270
|
+
return { isError: true, content: [{ type: 'text', text: 'You must explicitly accept the Fodda Terms of Service and Privacy Policy to proceed.' }] };
|
|
4271
|
+
}
|
|
4272
|
+
if (!apiKey) {
|
|
4273
|
+
return { content: [{ type: 'text', text: 'Your Fodda credentials are missing. Add Fodda as a connector to begin (or continue) onboarding: https://www.fodda.ai/join-experts' }] };
|
|
4274
|
+
}
|
|
4275
|
+
try {
|
|
4276
|
+
const result = await foddaRequest('POST', '/api/prepare-voice-interview', apiKey, resolveUserId(userId, uid), {
|
|
4277
|
+
action: 'expertise_analysis',
|
|
4278
|
+
voiceStudyRaw: voiceStudy,
|
|
4279
|
+
expertTopicsRaw: expertTopics,
|
|
4280
|
+
termsAccepted: true
|
|
4281
|
+
});
|
|
4282
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
4283
|
+
}
|
|
4284
|
+
catch (err) {
|
|
4285
|
+
return { isError: true, content: [{ type: 'text', text: parseWebsiteError(err) }] };
|
|
4286
|
+
}
|
|
4287
|
+
});
|
|
4288
|
+
server.tool('get_detected_themes', 'Fetch the detected themes derived from the expertise analysis and background research. Reframe the introduction using the ACTUAL count of themes returned (themes.length): "N expert topics were detected from the material you shared. These become the foundation of your Human Agent\'s expertise and shape the questionnaire for your interview. This is your moment to decide what your Human Agent is expert in — the selection matters." NEVER hardcode a count. Present all identified topics in one single numbered list, or state explicitly "recommending N of M". [FLOW VISUALIZATION - REQUIRED IF SUPPORTED]: You MUST render the onboarding path as a visual horizontal stepper using your client\'s diagram, SVG, or HTML rendering tool (marking stage 5 "Choose your themes" as "You are here", #663399 fill, #ffffff text). FOR THEME SELECTION CARDS (STAGE 5): Never pair a hard-coded light fill (#f5f0ff) with theme-inherited foreground text (which flips to white in dark mode). Either (1) use native client surface and text tokens for card backgrounds and body text so light/dark mode auto-adjusts, reserving #663399 for borders, checkboxes, and active dots; or (2) if prescribing exact hexes, ALWAYS pin foreground and background together — a #f5f0ff fill MUST carry dark-purple text (#3C3489 or #26215C), never inherited or theme-default text. Never expose developer meta-text to the expert.', {
|
|
4289
|
+
userId: z.string().optional().describe('Optional user identifier.')
|
|
4290
|
+
}, { title: 'the detected expertise themes list', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ userId: uid }) => {
|
|
4291
|
+
if (!apiKey) {
|
|
4292
|
+
return { content: [{ type: 'text', text: 'Your Fodda credentials are missing. Add Fodda as a connector to begin (or continue) onboarding: https://www.fodda.ai/join-experts' }] };
|
|
4293
|
+
}
|
|
4294
|
+
try {
|
|
4295
|
+
const result = await foddaRequest('GET', '/api/onboarding-themes', apiKey, resolveUserId(userId, uid));
|
|
4296
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
4297
|
+
}
|
|
4298
|
+
catch (err) {
|
|
4299
|
+
return { isError: true, content: [{ type: 'text', text: parseWebsiteError(err) }] };
|
|
4300
|
+
}
|
|
4301
|
+
});
|
|
4302
|
+
server.tool('confirm_themes', 'Confirm the selected themes to generate the interview questionnaire tailored to probe forward predictions, contrarian industry stances, and practical methodology edge cases for their live deep-dive interview. [FLOW VISUALIZATION - REQUIRED IF SUPPORTED]: You MUST render the onboarding path as a visual horizontal stepper using your client\'s diagram, SVG, or HTML rendering tool (marking stage 6 "Expertise Deep-Dive (Audio)" as "You are here", #663399 fill, #ffffff text). Present the 8-question interview questionnaire arc and instruct the expert to schedule their expertise deep-dive audio interview using the schedule_interview tool. Never expose developer meta-text to the expert.', {
|
|
4303
|
+
themes: z.array(z.string()).describe("Array of confirmed theme names"),
|
|
4304
|
+
userId: z.string().optional().describe('Optional user identifier.')
|
|
4305
|
+
}, { title: 'the theme confirmation step', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ themes, userId: uid }) => {
|
|
4306
|
+
if (!apiKey) {
|
|
4307
|
+
return { content: [{ type: 'text', text: 'Your Fodda credentials are missing. Add Fodda as a connector to begin (or continue) onboarding: https://www.fodda.ai/join-experts' }] };
|
|
4308
|
+
}
|
|
4309
|
+
try {
|
|
4310
|
+
const result = await foddaRequest('POST', '/api/generate-questions', apiKey, resolveUserId(userId, uid), { confirmedThemes: themes });
|
|
4311
|
+
if (!result || result.success === false) {
|
|
4312
|
+
return {
|
|
4313
|
+
isError: true,
|
|
4314
|
+
content: [{
|
|
4315
|
+
type: 'text',
|
|
4316
|
+
text: "Theme confirmation didn't complete — the interview questionnaire wasn't generated. Please call confirm_themes again to retry. Do NOT proceed to schedule_interview yet."
|
|
4317
|
+
}]
|
|
4318
|
+
};
|
|
4319
|
+
}
|
|
4320
|
+
const extendedResult = {
|
|
4321
|
+
...result,
|
|
4322
|
+
next: 'schedule_interview',
|
|
4323
|
+
message: 'The Expertise Deep-Dive (Audio) interview is your next step — please schedule it by calling the schedule_interview tool.'
|
|
4324
|
+
};
|
|
4325
|
+
return { content: [{ type: 'text', text: JSON.stringify(extendedResult, null, 2) }] };
|
|
4326
|
+
}
|
|
4327
|
+
catch (err) {
|
|
4328
|
+
return { isError: true, content: [{ type: 'text', text: parseWebsiteError(err) }] };
|
|
4329
|
+
}
|
|
4330
|
+
});
|
|
4331
|
+
server.tool('get_onboarding_status', 'Check the status of the expert onboarding process. The identity is derived from your connector session. IMPORTANT: analystId is an internal reference — do not display it to the expert.', {
|
|
4332
|
+
analystId: z.string().optional().describe('Optional specific Analyst ID to check status for.'),
|
|
4333
|
+
userId: z.string().optional().describe('Optional user identifier.')
|
|
4334
|
+
}, { title: 'the onboarding progress status check', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ analystId, userId: uid }) => {
|
|
4335
|
+
if (!apiKey) {
|
|
4336
|
+
return { content: [{ type: 'text', text: 'Your Fodda credentials are missing. Add Fodda as a connector to begin (or continue) onboarding: https://www.fodda.ai/join-experts' }] };
|
|
4337
|
+
}
|
|
4338
|
+
try {
|
|
4339
|
+
const userEmail = resolveUserId(userId, uid);
|
|
4340
|
+
let path = '/api/onboarding-status';
|
|
4341
|
+
if (analystId) {
|
|
4342
|
+
path += `?analystId=${encodeURIComponent(analystId)}`;
|
|
4343
|
+
}
|
|
4344
|
+
const result = await foddaRequest('GET', path, apiKey, userEmail);
|
|
4345
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
4346
|
+
}
|
|
4347
|
+
catch (err) {
|
|
4348
|
+
return { isError: true, content: [{ type: 'text', text: parseWebsiteError(err) }] };
|
|
4349
|
+
}
|
|
4350
|
+
});
|
|
4351
|
+
server.tool('schedule_interview', 'Schedule your ~15–20 minute expertise deep-dive interview with the Fodda AI interviewer to explore forward-looking predictions, contrarian views, and practical methodology edge cases. You can specify a datetime (UTC ISO string) and localTimeStr (human-readable time, e.g. "Tuesday, July 14 at 3:00 PM EDT"), or request an instant interview now.\n\n[FLOW VISUALIZATION - REQUIRED IF SUPPORTED]\nWhen presenting booking confirmation and the Google Meet join link, you MUST render the onboarding path as a visual horizontal stepper using your client\'s diagram, SVG, or HTML rendering tool (marking stage 6 "Expertise Deep-Dive (Audio)" as "You are here", #663399 fill, #ffffff text). Never output plain code-formatted text ladders. Never output internal developer QA history, past trial runs, or technical meta-comments.\n\n[SCHEDULING BEHAVIOR]\nIf the expert chooses to schedule for later rather than start now: first check whether a calendar tool/connector is available in this session. If one is, look at their availability and proactively offer 2-3 specific open windows within the next 24 hours, in their local timezone, instead of asking them to name a time cold. If no calendar is connected, simply ask for a preferred day and time (assume the expert\'s local timezone; confirm it if unclear).\n\n[AFTER BOOKING]\nOnce a time is confirmed and this tool returns a Google Meet join link, add the interview to the expert\'s calendar automatically - create the event at the booked time, titled "Fodda Human Agent — expertise deep-dive interview with <the expert\'s full name>", with the join link inside it - rather than asking whether they want it added. Then confirm in one line that it is on their calendar, and include the calendar event link in that confirmation. Only skip the auto-add if no calendar tool is available in the session.', {
|
|
4352
|
+
datetime: z.string().optional().describe('ISO-8601 UTC datetime for the scheduled interview (e.g. "2026-07-14T19:00:00.000Z")'),
|
|
4353
|
+
localTimeStr: z.string().optional().describe('Human-readable local time representation (e.g. "Tuesday, July 14 at 3:00 PM EDT")'),
|
|
4354
|
+
now: z.boolean().optional().describe('Set to true to dispatch an instant interview bot immediately'),
|
|
4355
|
+
userId: z.string().optional().describe('Optional user identifier.')
|
|
4356
|
+
}, { title: 'the interview scheduling step', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ datetime, localTimeStr, now, userId: uid }) => {
|
|
4357
|
+
if (!apiKey) {
|
|
4358
|
+
return { content: [{ type: 'text', text: 'Your Fodda credentials are missing. Add Fodda as a connector to begin (or continue) onboarding: https://www.fodda.ai/join-experts' }] };
|
|
4359
|
+
}
|
|
4360
|
+
try {
|
|
4361
|
+
const userEmail = resolveUserId(userId, uid);
|
|
4362
|
+
const result = await foddaRequest('POST', '/api/voice-interview/request', apiKey, userEmail, {
|
|
4363
|
+
datetime,
|
|
4364
|
+
localTimeStr,
|
|
4365
|
+
now
|
|
4366
|
+
});
|
|
4367
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
4368
|
+
}
|
|
4369
|
+
catch (err) {
|
|
4370
|
+
return { isError: true, content: [{ type: 'text', text: parseWebsiteError(err) }] };
|
|
4371
|
+
}
|
|
4372
|
+
});
|
|
4373
|
+
server.tool('get_my_earnings', 'Check the expert\'s Fodda earnings.', {
|
|
4374
|
+
userId: z.string().optional().describe('Optional user identifier.')
|
|
4375
|
+
}, { title: 'Get My Earnings', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ userId: uid }) => {
|
|
4376
|
+
try {
|
|
4377
|
+
// Mapped to /v1/analysts/me/earnings as requested by Brief 402 + Agentic Access constraints
|
|
4378
|
+
const result = await foddaRequest('GET', '/v1/analysts/me/earnings', apiKey, resolveUserId(userId, uid));
|
|
4379
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
4380
|
+
}
|
|
4381
|
+
catch (err) {
|
|
4382
|
+
const msg = err.response?.data?.error?.message || err.response?.data?.message || err.message;
|
|
4383
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: msg }) }] };
|
|
4384
|
+
}
|
|
4385
|
+
});
|
|
4386
|
+
if (allowedTools && (Array.isArray(allowedTools) ? allowedTools.length > 0 : allowedTools.size > 0)) {
|
|
4387
|
+
const allowedSet = allowedTools instanceof Set ? allowedTools : new Set(allowedTools);
|
|
4388
|
+
const registered = server._registeredTools || {};
|
|
4389
|
+
for (const [name, tool] of Object.entries(registered)) {
|
|
4390
|
+
if (!allowedSet.has(name)) {
|
|
4391
|
+
if (typeof tool.disable === 'function') {
|
|
4392
|
+
tool.disable();
|
|
4393
|
+
}
|
|
4394
|
+
else {
|
|
4395
|
+
delete registered[name];
|
|
4396
|
+
}
|
|
4397
|
+
}
|
|
4398
|
+
}
|
|
4399
|
+
}
|
|
3086
4400
|
return server;
|
|
3087
4401
|
}
|
|
3088
4402
|
//# sourceMappingURL=toolHandlers.js.map
|