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.
Files changed (186) hide show
  1. package/README.md +14 -16
  2. package/dist/a2aHandler.d.ts +33 -3
  3. package/dist/a2aHandler.d.ts.map +1 -1
  4. package/dist/a2aHandler.js +189 -36
  5. package/dist/a2aHandler.js.map +1 -1
  6. package/dist/agentFacts.d.ts +21 -0
  7. package/dist/agentFacts.d.ts.map +1 -0
  8. package/dist/agentFacts.js +72 -0
  9. package/dist/agentFacts.js.map +1 -0
  10. package/dist/agents/fodda-researcher/index.d.ts +6 -0
  11. package/dist/agents/fodda-researcher/index.d.ts.map +1 -1
  12. package/dist/agents/fodda-researcher/index.js +18 -0
  13. package/dist/agents/fodda-researcher/index.js.map +1 -1
  14. package/dist/agents/fodda-researcher/skills.d.ts +1 -1
  15. package/dist/agents/fodda-researcher/skills.d.ts.map +1 -1
  16. package/dist/agents/fodda-researcher/skills.js +8 -3
  17. package/dist/agents/fodda-researcher/skills.js.map +1 -1
  18. package/dist/brandTemplate.d.ts +1 -1
  19. package/dist/brandTemplate.d.ts.map +1 -1
  20. package/dist/brandTemplate.js +179 -11
  21. package/dist/brandTemplate.js.map +1 -1
  22. package/dist/catalogCache.d.ts +14 -13
  23. package/dist/catalogCache.d.ts.map +1 -1
  24. package/dist/catalogCache.js +266 -67
  25. package/dist/catalogCache.js.map +1 -1
  26. package/dist/coverageRelevance.d.ts +156 -0
  27. package/dist/coverageRelevance.d.ts.map +1 -0
  28. package/dist/coverageRelevance.js +1062 -0
  29. package/dist/coverageRelevance.js.map +1 -0
  30. package/dist/deepResearch.d.ts +43 -0
  31. package/dist/deepResearch.d.ts.map +1 -0
  32. package/dist/deepResearch.js +498 -0
  33. package/dist/deepResearch.js.map +1 -0
  34. package/dist/enrichment.d.ts.map +1 -1
  35. package/dist/enrichment.js +51 -1
  36. package/dist/enrichment.js.map +1 -1
  37. package/dist/errorHandling.d.ts +1 -1
  38. package/dist/errorHandling.d.ts.map +1 -1
  39. package/dist/errorHandling.js +26 -6
  40. package/dist/errorHandling.js.map +1 -1
  41. package/dist/index.d.ts +9 -0
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +548 -38
  44. package/dist/index.js.map +1 -1
  45. package/dist/linkedinEngine.d.ts +150 -0
  46. package/dist/linkedinEngine.d.ts.map +1 -0
  47. package/dist/linkedinEngine.js +696 -0
  48. package/dist/linkedinEngine.js.map +1 -0
  49. package/dist/oauthRegisterShim.d.ts +13 -0
  50. package/dist/oauthRegisterShim.d.ts.map +1 -0
  51. package/dist/oauthRegisterShim.js +83 -0
  52. package/dist/oauthRegisterShim.js.map +1 -0
  53. package/dist/pricingCache.d.ts +6 -8
  54. package/dist/pricingCache.d.ts.map +1 -1
  55. package/dist/pricingCache.js +69 -13
  56. package/dist/pricingCache.js.map +1 -1
  57. package/dist/queryCache.d.ts +2 -0
  58. package/dist/queryCache.d.ts.map +1 -1
  59. package/dist/queryCache.js +11 -2
  60. package/dist/queryCache.js.map +1 -1
  61. package/dist/resources.d.ts +27 -0
  62. package/dist/resources.d.ts.map +1 -0
  63. package/dist/resources.js +126 -0
  64. package/dist/resources.js.map +1 -0
  65. package/dist/sessionTracker.d.ts +24 -2
  66. package/dist/sessionTracker.d.ts.map +1 -1
  67. package/dist/sessionTracker.js +198 -4
  68. package/dist/sessionTracker.js.map +1 -1
  69. package/dist/systemPrompt.d.ts +3 -3
  70. package/dist/systemPrompt.d.ts.map +1 -1
  71. package/dist/systemPrompt.js +81 -61
  72. package/dist/systemPrompt.js.map +1 -1
  73. package/dist/telemetry.d.ts +43 -0
  74. package/dist/telemetry.d.ts.map +1 -0
  75. package/dist/telemetry.js +101 -0
  76. package/dist/telemetry.js.map +1 -0
  77. package/dist/test_analyst.js +2 -2
  78. package/dist/test_analyst.js.map +1 -1
  79. package/dist/test_book_a_call_live.d.ts +2 -0
  80. package/dist/test_book_a_call_live.d.ts.map +1 -0
  81. package/dist/test_book_a_call_live.js +148 -0
  82. package/dist/test_book_a_call_live.js.map +1 -0
  83. package/dist/test_brand_intelligence_endpoint.d.ts +2 -0
  84. package/dist/test_brand_intelligence_endpoint.d.ts.map +1 -0
  85. package/dist/test_brand_intelligence_endpoint.js +20 -0
  86. package/dist/test_brand_intelligence_endpoint.js.map +1 -0
  87. package/dist/test_coverage_relevance.d.ts +12 -0
  88. package/dist/test_coverage_relevance.d.ts.map +1 -0
  89. package/dist/test_coverage_relevance.js +121 -0
  90. package/dist/test_coverage_relevance.js.map +1 -0
  91. package/dist/test_dcr_and_legacy_deprecation.d.ts +2 -0
  92. package/dist/test_dcr_and_legacy_deprecation.d.ts.map +1 -0
  93. package/dist/test_dcr_and_legacy_deprecation.js +148 -0
  94. package/dist/test_dcr_and_legacy_deprecation.js.map +1 -0
  95. package/dist/test_financial_snapshot_render.d.ts +2 -0
  96. package/dist/test_financial_snapshot_render.d.ts.map +1 -0
  97. package/dist/test_financial_snapshot_render.js +167 -0
  98. package/dist/test_financial_snapshot_render.js.map +1 -0
  99. package/dist/test_gap_alert.d.ts +12 -0
  100. package/dist/test_gap_alert.d.ts.map +1 -0
  101. package/dist/test_gap_alert.js +43 -0
  102. package/dist/test_gap_alert.js.map +1 -0
  103. package/dist/test_list_graphs_topics.d.ts +2 -0
  104. package/dist/test_list_graphs_topics.d.ts.map +1 -0
  105. package/dist/test_list_graphs_topics.js +26 -0
  106. package/dist/test_list_graphs_topics.js.map +1 -0
  107. package/dist/test_live_endpoint_runner.d.ts +2 -0
  108. package/dist/test_live_endpoint_runner.d.ts.map +1 -0
  109. package/dist/test_live_endpoint_runner.js +65 -0
  110. package/dist/test_live_endpoint_runner.js.map +1 -0
  111. package/dist/test_local_list_graphs.d.ts +2 -0
  112. package/dist/test_local_list_graphs.d.ts.map +1 -0
  113. package/dist/test_local_list_graphs.js +53 -0
  114. package/dist/test_local_list_graphs.js.map +1 -0
  115. package/dist/test_next_moves.d.ts +2 -0
  116. package/dist/test_next_moves.d.ts.map +1 -0
  117. package/dist/test_next_moves.js +349 -0
  118. package/dist/test_next_moves.js.map +1 -0
  119. package/dist/test_next_moves_transcripts.d.ts +2 -0
  120. package/dist/test_next_moves_transcripts.d.ts.map +1 -0
  121. package/dist/test_next_moves_transcripts.js +413 -0
  122. package/dist/test_next_moves_transcripts.js.map +1 -0
  123. package/dist/test_nike_cmo_alias.d.ts +2 -0
  124. package/dist/test_nike_cmo_alias.d.ts.map +1 -0
  125. package/dist/test_nike_cmo_alias.js +76 -0
  126. package/dist/test_nike_cmo_alias.js.map +1 -0
  127. package/dist/test_session_next_moves_telemetry.d.ts +2 -0
  128. package/dist/test_session_next_moves_telemetry.d.ts.map +1 -0
  129. package/dist/test_session_next_moves_telemetry.js +100 -0
  130. package/dist/test_session_next_moves_telemetry.js.map +1 -0
  131. package/dist/test_token_resolution.d.ts +2 -0
  132. package/dist/test_token_resolution.d.ts.map +1 -0
  133. package/dist/test_token_resolution.js +82 -0
  134. package/dist/test_token_resolution.js.map +1 -0
  135. package/dist/toolHandlers.d.ts +7 -3
  136. package/dist/toolHandlers.d.ts.map +1 -1
  137. package/dist/toolHandlers.js +1678 -364
  138. package/dist/toolHandlers.js.map +1 -1
  139. package/dist/tools.d.ts +7 -2
  140. package/dist/tools.d.ts.map +1 -1
  141. package/dist/tools.js +12 -3
  142. package/dist/tools.js.map +1 -1
  143. package/dist/types.d.ts +9 -46
  144. package/dist/types.d.ts.map +1 -1
  145. package/dist/types.js +6 -0
  146. package/dist/types.js.map +1 -1
  147. package/dist/verify_tools_endpoint.js +4 -3
  148. package/dist/verify_tools_endpoint.js.map +1 -1
  149. package/package.json +5 -2
  150. package/server.json +16 -3
  151. package/dist/scripts/simulate_gemini.d.ts +0 -2
  152. package/dist/scripts/simulate_gemini.d.ts.map +0 -1
  153. package/dist/scripts/simulate_gemini.js +0 -100
  154. package/dist/scripts/simulate_gemini.js.map +0 -1
  155. package/dist/scripts/verify_registry.d.ts +0 -2
  156. package/dist/scripts/verify_registry.d.ts.map +0 -1
  157. package/dist/scripts/verify_registry.js +0 -77
  158. package/dist/scripts/verify_registry.js.map +0 -1
  159. package/dist/src/index.d.ts +0 -2
  160. package/dist/src/index.d.ts.map +0 -1
  161. package/dist/src/index.js +0 -332
  162. package/dist/src/index.js.map +0 -1
  163. package/dist/src/tools.d.ts +0 -12
  164. package/dist/src/tools.d.ts.map +0 -1
  165. package/dist/src/tools.js +0 -104
  166. package/dist/src/tools.js.map +0 -1
  167. package/dist/src/types.d.ts +0 -44
  168. package/dist/src/types.d.ts.map +0 -1
  169. package/dist/src/types.js +0 -2
  170. package/dist/src/types.js.map +0 -1
  171. package/dist/src/verify.d.ts +0 -2
  172. package/dist/src/verify.d.ts.map +0 -1
  173. package/dist/src/verify.js +0 -47
  174. package/dist/src/verify.js.map +0 -1
  175. package/dist/src/verify_hmac.d.ts +0 -2
  176. package/dist/src/verify_hmac.d.ts.map +0 -1
  177. package/dist/src/verify_hmac.js +0 -106
  178. package/dist/src/verify_hmac.js.map +0 -1
  179. package/dist/src/verify_tools_endpoint.d.ts +0 -2
  180. package/dist/src/verify_tools_endpoint.d.ts.map +0 -1
  181. package/dist/src/verify_tools_endpoint.js +0 -40
  182. package/dist/src/verify_tools_endpoint.js.map +0 -1
  183. package/dist/test-waverunner-response.d.ts +0 -2
  184. package/dist/test-waverunner-response.d.ts.map +0 -1
  185. package/dist/test-waverunner-response.js +0 -20
  186. package/dist/test-waverunner-response.js.map +0 -1
@@ -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.0';
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: discovered?.tools.map(t => `${s.id}_${t.name}`) || [],
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 empty capabilities and handlers to silence directory warnings
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
- for (const tool of discovered.tools) {
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.') }, { title: `${discovered.skill_name}: ${tool.name}`, readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true }, async ({ arguments: toolArgs }) => {
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 msg = err.response?.data?.error?.message || err.response?.data?.message || err.message;
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. Deprecated: waldo, psfk (use retail/tech/food/travel/fashion/beauty/sports instead).', { userId: z.string().optional().describe('Optional user identifier. Authenticated users are identified automatically via API key. For trial users, this helps track usage.') }, { title: 'List Knowledge Graphs', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ userId: uid }) => {
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', 'List available Synthetic Analysts — named expert personas grounded in specific knowledge graphs. Each analyst has a unique voice, methodology, and domain expertise that cannot be replicated by web search. Use when user asks to "talk to" or "consult" an expert, or when you need specialist depth on culture, strategy, or innovation topics.', { userId: z.string().optional().describe('Optional user identifier.') }, { title: 'List Synthetic Analysts', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ userId: uid }) => {
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 data = await foddaRequest('GET', '/v1/analysts', apiKey, resolveUserId(userId, uid));
461
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
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. Location terms are auto-detected and used to filter results geographically.'),
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 skills (Paralogy, Igloo, etc.) for this query only. Use when the user says "without skills", "skip Paralogy", or "just the raw results". Default: false.')
480
- }, { title: 'Search Knowledge Graph', readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: false }, async ({ graphId, query, userId: uid, limit, use_semantic, include_evidence, skip_skills }) => {
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
- // If no graphId or deprecated 'psfk', use smart 2-step routing
494
- if (!graphId || graphId === 'psfk') {
495
- // Step 1: Score query against graph metadata to find relevant graphs
496
- const relevantGraphs = getRelevantGraphs(query);
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 rows = Array.isArray(fulfilled.value) ? fulfilled.value : (fulfilled.value?.rows || []);
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
- data = { rows: finalRows, dataStatus: allRows.length > 0 ? 'ok' : 'NO_MATCH', _routed_graphs: actualSourceGraphs };
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: 'The graph has limited coverage on this topic. These are the closest matches.' };
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
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
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
- ? `── BRAND WIDGET: READY ──\nThe widget is fully populated. Call show_widget with the widget_html as-is. Do NOT modify the HTML.`
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: false }, async ({ brand_name, userId: uid, graph_ids, include_evidence, max_evidence }) => {
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
- return { content: [
1679
- { type: 'text', text: '── WIDGET HTML ──\nIf your client supports HTML visualization (show_widget, visualize:show_widget, or artifacts), pass this HTML verbatim. Do not rewrite or restyle.\n\n' + widget.widget_html },
1680
- { type: 'text', text: EDITORIAL_INSTRUCTION },
1681
- ] };
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', 'Targeted pull for macro/institutional data — research workflows include this automatically; call directly only for standalone economic context. Gets real-time market data from 80+ authoritative sources in a single call — economic indicators, trade statistics, consumer demand signals, research trends, demographics, and more. The server automatically selects the most relevant sources for your query. Returns categorized data blocks (demand_signals, economic_context, market_data, research_signals, demographic_context) with source attribution for citations. Uses 5 tokens ($2.50 via SPT) per standalone use.', {
1696
- query: z.string().describe("The topic or query to get supplemental data for (e.g., 'sustainable packaging', 'tequila spirits market', 'Gen Z beauty')"),
1697
- domain: z.string().optional().describe("Domain hint to improve source routing: 'retail', 'beauty', 'fashion', 'sports', 'food', 'technology', 'culture', 'travel', 'design'. If omitted, inferred from query."),
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: false }, async ({ query, domain, brands, graph_ids, userId: uid }) => {
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
- activeSupplementalJobs.set(jobId, { status: 'COMPLETE', result: JSON.stringify(data, null, 2) });
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
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
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
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
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
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
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', "Find specific numbers, market sizes, growth rates, and quantitative data points across Fodda's knowledge graphs. Each result links back to the expert trend it supports. Use when a question asks for specific statistics — try this BEFORE supplemental data tools, as Fodda's experts may have already curated the answer. Works on ALL graphs — domain, expert, and report graphs. Search multiple graphs for best coverage.", {
1870
- graph_id: z.string().describe("Graph ID to search. Works on ALL graphs — PSFK curated ('retail', 'fashion', 'beauty', 'sports', 'sic', 'ce-design', 'pew') AND expert graphs. Search across multiple graphs for best coverage."),
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
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
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', 'Find expert quotes, editorial analysis, and strategic perspectives on a topic — sourced from named strategists and industry leaders. Returns categorized evidence (metrics, quotes, interpretations, signals) with source attribution and parent trend context. Works on ALL graphs. Use when you need authoritative voices, strategic framing, or analytical depth that web search cannot provide.', {
1908
- graph_id: z.string().describe("Graph ID to search. Works on ALL graphs — PSFK curated ('retail', 'sic', 'beauty', 'sports', 'fashion', 'ce-design', 'pew') AND expert graphs. Search across multiple graphs for best coverage."),
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: metric, quote, interpretation, signal, or 'all' (default: 'metric,quote,interpretation')"),
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 || 'metric,quote,interpretation';
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
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
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', 'Targeted pull: use when you want ONLY earnings data for a known ticker/sector — not full research. For questions that mix earnings with trends, use deep_research_topic, which includes earnings automatically. Returns structured evidence from public company earnings calls — management commentary, guidance, key topics, and analyst Q&A. Use for cross-company comparisons ("what are hotel companies saying about labor costs?"), industry-level queries ("earnings intelligence for consumer electronics"), or explicit earnings requests. For single-brand earnings, use brand_tracker instead — it includes earningsIntelligence automatically. Results include a source field: "knowledge_graph" (high confidence, structured Neo4j data) or "web_supplemental" (backfilled via web search). Uses 5 tokens ($2.50 via SPT). For cross-company thematic comparisons. For per-ticker data (SWOT, sentiment, guidance), use get_company_earnings instead.', {
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', 'Targeted pull: use when you want ONLY earnings data for a known ticker/sector — not full research. For questions that mix earnings with trends, use deep_research_topic, which includes earnings automatically. Detects divergence between analyst concerns and management responses in earnings calls — surfaces where executives are deflecting, reframing, or avoiding specific topics. Premium intelligence — shows the gap between what Wall Street is worried about and what companies are saying. Results include deflected topics, concern-vs-response framing, and connections to Fodda trends via :VALIDATES edges. Use for "where are executives deflecting?" or "divergence in [sector] earnings." Uses 5 tokens ($2.50 via SPT). Detects management deflection of analyst concerns.', {
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', 'Get per-ticker earnings intelligence for consumer-sector public companies. Returns SWOT scores, CEO/analyst sentiment, guidance status, competitive analysis, and validated consumer trends from Fodda\'s quarterly analysis pipeline. Uses 0–15 tokens depending on view (coverage is free, snapshot/history/guidance = 10, qa = 5, compare = 15). Use this for company-specific data. Use get_earnings_intelligence for cross-company thematic comparisons.', {
2043
- view: z.enum(['snapshot', 'history', 'qa', 'compare', 'guidance', 'coverage']).default('snapshot').describe('snapshot: full quarterly record with SWOT, sentiment, guidance, validated trends (10 tokens). history: SWOT/sentiment scores across quarters for trend analysis (10 tokens). qa: per-analyst Q&A entries with thematic tagging (5 tokens). compare: side-by-side comparison of 2-5 tickers (15 tokens). guidance: cross-coverage guidance changes — raised/cut/withdrawn (10 tokens). coverage: list all covered tickers (free).'),
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 ((view === 'snapshot' || view === 'history' || view === 'qa') && !ticker) {
2056
- return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'MISSING_PARAM', message: `The "${view}" view requires a ticker parameter (e.g. ticker: "NKE").` }) }] };
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 (view === 'compare' && !tickers) {
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 (view) {
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 exit reasons to the Fodda team via email and Slack. Call this whenever a user shares feedback — including when they want to leave, report a problem, or suggest an improvement.', {
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
- }, { title: 'Send Feedback', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ feedback, user_email, category }) => {
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 slackText = [
2983
+ const slackLines = [
2236
2984
  `<@U0AU49JG7AS> ${emoji} *User Feedback*`,
2237
2985
  `👤 ${userLabel}`,
2238
2986
  `📁 Category: ${catLabel}`,
2239
2987
  `📝 ${feedback}`,
2240
- `→ Check if this needs a response or product action.`,
2241
- ].join('\n');
2242
- postToSlack(slackText).catch(() => { });
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@fodda.ai'],
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} tokens/month. Check your email to confirm and then log in at https://app.fodda.ai to grab your MCP URL and update your connector.`,
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
- monthly_token_limit: response.data.monthlyTokens || 100,
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) => ({ ...r, _source_graph: gid }));
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
- return { content: [{ type: 'text', text: JSON.stringify(brainstormMap, null, 2) }] };
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. Costs 20 API calls per run. Supports topic research or brand intelligence report types.', {
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. Uses 15 tokens ($7.50 via SPT).', {
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
- if (!extractedText) {
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. Depth: "light" (20 API calls, faster single-pass) or "heavy" (30 API calls, comprehensive multi-pass with validation). Automatically includes earnings-call intelligence and macro/supplemental data when the topic warrants it (public companies, sectors, economic conditions). You do not need to call the earnings or supplemental tools separately before or after.', {
2722
- query: z.string().describe('The research query/topic'),
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
- depth: z.enum(['light', 'heavy']).optional().describe('Research depth: "light" for faster single-pass (20 API calls), "heavy" for comprehensive multi-pass (30 API calls). Defaults to "light".'),
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 isHeavy = depth === 'heavy';
2729
- const tokenCost = isHeavy ? 3 : 2; // Waverunner trial pool tokens
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
- // Run research in the background to avoid Claude Web timeout
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
- let geminiPromise = waverunnerRequest('deep_dive', tokenCost, apiKey, resolvedUserId, interactionPayload);
2906
- let result;
2907
- try {
2908
- result = await geminiPromise;
2909
- }
2910
- catch (primaryErr) {
2911
- const errMsg = primaryErr?.response?.data?.error?.message || primaryErr?.message || '';
2912
- const isCapacity = errMsg.includes('high demand') || errMsg.includes('overloaded') || errMsg.includes('503');
2913
- if (isCapacity && geminiModel !== 'gemini-2.5-flash') {
2914
- console.error(`[deep_research_topic] ${geminiModel} capacity error — retrying with gemini-2.5-flash`);
2915
- interactionPayload.model = 'gemini-2.5-flash';
2916
- geminiPromise = waverunnerRequest('deep_dive', tokenCost, apiKey, resolvedUserId, interactionPayload);
2917
- result = await geminiPromise;
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: graphIds, foddaRequest, spt: sptCtx.token });
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: graphIds, foddaRequest })
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
- const header = [
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 searching the graph and the live web. Job ID: ${jobId}\n\nsource_plan (sources the router selected and why — earnings/supplemental are included automatically when relevant, so no separate earnings or supplemental calls are needed):\n${JSON.stringify(sourcePlan, null, 2)}\n\nIMPORTANT: You must use the check_research_status tool with this Job ID to poll the status of the job and retrieve the report.`
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
- return { content: [{ type: 'text', text: job.result }] };
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. Each analyst has a unique methodology, domain expertise, and analytical lens that produces insights distinct from generic search or standard graph queries. Use when the user asks to talk to or consult a specific expert, or when you need a specialist perspective on culture, strategy, or innovation topics. Call list_analysts first to discover available analyst_id values. Responses may include a coverage status (in/adjacent/out), source attribution, and referrals to other expert graphs. Referrals MUST be presented in third-person platform voice (not the expert\'s voice) with an offer to query the referred graph. The analyst researches on your behalf: they can search Fodda\'s graphs, earnings intelligence, and supplemental data mid-consultation, and may refer or consult other analysts. Their research reads bill to you at standard rates ($0.50/call) and are itemized in `sources_used`.', {
3028
- analyst_id: z.string().describe("The analyst ID (e.g., 'ben-dietz-sic')"),
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 sourceLines = result.sources_used.map((s) => s.url ? `- ${s.label || s.name || 'Source'}: ${s.url}` : `- ${s.label || s.name || 'Source'}`);
3056
- parts.push(`--- SOURCES USED ---\n${sourceLines.join('\n')}`);
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 refLines = result.referrals.map((r, i) => `${i + 1}. ${r.name} by ${r.curator || 'unknown'} — ${r.reason || 'related expertise'}`);
3060
- parts.push(`--- REFERRALS (deliver these in 3rd person as the platform, NOT in the expert's voice) ---\n${refLines.join('\n')}`);
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 { content: [{ type: 'text', text: parts.join('\n') }] };
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 (60s). The upstream API is processing a complex query with tool calls. Retry in a moment, or use search_graph / get_expert_intelligence for faster results.`,
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