clementine-agent 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/.env.example +44 -0
  2. package/LICENSE +21 -0
  3. package/README.md +795 -0
  4. package/dist/agent/agent-manager.d.ts +69 -0
  5. package/dist/agent/agent-manager.js +441 -0
  6. package/dist/agent/assistant.d.ts +225 -0
  7. package/dist/agent/assistant.js +3888 -0
  8. package/dist/agent/auto-update.d.ts +32 -0
  9. package/dist/agent/auto-update.js +186 -0
  10. package/dist/agent/daily-planner.d.ts +24 -0
  11. package/dist/agent/daily-planner.js +379 -0
  12. package/dist/agent/execution-advisor.d.ts +10 -0
  13. package/dist/agent/execution-advisor.js +272 -0
  14. package/dist/agent/hooks.d.ts +45 -0
  15. package/dist/agent/hooks.js +564 -0
  16. package/dist/agent/insight-engine.d.ts +66 -0
  17. package/dist/agent/insight-engine.js +225 -0
  18. package/dist/agent/intent-classifier.d.ts +48 -0
  19. package/dist/agent/intent-classifier.js +214 -0
  20. package/dist/agent/link-extractor.d.ts +19 -0
  21. package/dist/agent/link-extractor.js +90 -0
  22. package/dist/agent/mcp-bridge.d.ts +62 -0
  23. package/dist/agent/mcp-bridge.js +435 -0
  24. package/dist/agent/metacognition.d.ts +66 -0
  25. package/dist/agent/metacognition.js +221 -0
  26. package/dist/agent/orchestrator.d.ts +81 -0
  27. package/dist/agent/orchestrator.js +790 -0
  28. package/dist/agent/profiles.d.ts +22 -0
  29. package/dist/agent/profiles.js +91 -0
  30. package/dist/agent/prompt-cache.d.ts +24 -0
  31. package/dist/agent/prompt-cache.js +68 -0
  32. package/dist/agent/prompt-evolver.d.ts +28 -0
  33. package/dist/agent/prompt-evolver.js +279 -0
  34. package/dist/agent/role-scaffolds.d.ts +28 -0
  35. package/dist/agent/role-scaffolds.js +433 -0
  36. package/dist/agent/safe-restart.d.ts +41 -0
  37. package/dist/agent/safe-restart.js +150 -0
  38. package/dist/agent/self-improve.d.ts +66 -0
  39. package/dist/agent/self-improve.js +1706 -0
  40. package/dist/agent/session-event-log.d.ts +114 -0
  41. package/dist/agent/session-event-log.js +233 -0
  42. package/dist/agent/skill-extractor.d.ts +72 -0
  43. package/dist/agent/skill-extractor.js +435 -0
  44. package/dist/agent/source-mods.d.ts +61 -0
  45. package/dist/agent/source-mods.js +230 -0
  46. package/dist/agent/source-preflight.d.ts +25 -0
  47. package/dist/agent/source-preflight.js +100 -0
  48. package/dist/agent/stall-guard.d.ts +62 -0
  49. package/dist/agent/stall-guard.js +109 -0
  50. package/dist/agent/strategic-planner.d.ts +60 -0
  51. package/dist/agent/strategic-planner.js +352 -0
  52. package/dist/agent/team-bus.d.ts +89 -0
  53. package/dist/agent/team-bus.js +556 -0
  54. package/dist/agent/team-router.d.ts +26 -0
  55. package/dist/agent/team-router.js +37 -0
  56. package/dist/agent/tool-loop-detector.d.ts +59 -0
  57. package/dist/agent/tool-loop-detector.js +242 -0
  58. package/dist/agent/workflow-runner.d.ts +36 -0
  59. package/dist/agent/workflow-runner.js +317 -0
  60. package/dist/agent/workflow-variables.d.ts +16 -0
  61. package/dist/agent/workflow-variables.js +62 -0
  62. package/dist/channels/discord-agent-bot.d.ts +101 -0
  63. package/dist/channels/discord-agent-bot.js +881 -0
  64. package/dist/channels/discord-bot-manager.d.ts +80 -0
  65. package/dist/channels/discord-bot-manager.js +262 -0
  66. package/dist/channels/discord-utils.d.ts +51 -0
  67. package/dist/channels/discord-utils.js +293 -0
  68. package/dist/channels/discord.d.ts +12 -0
  69. package/dist/channels/discord.js +1832 -0
  70. package/dist/channels/slack-agent-bot.d.ts +73 -0
  71. package/dist/channels/slack-agent-bot.js +320 -0
  72. package/dist/channels/slack-bot-manager.d.ts +66 -0
  73. package/dist/channels/slack-bot-manager.js +236 -0
  74. package/dist/channels/slack-utils.d.ts +39 -0
  75. package/dist/channels/slack-utils.js +189 -0
  76. package/dist/channels/slack.d.ts +11 -0
  77. package/dist/channels/slack.js +196 -0
  78. package/dist/channels/telegram.d.ts +10 -0
  79. package/dist/channels/telegram.js +235 -0
  80. package/dist/channels/webhook.d.ts +9 -0
  81. package/dist/channels/webhook.js +78 -0
  82. package/dist/channels/whatsapp.d.ts +11 -0
  83. package/dist/channels/whatsapp.js +181 -0
  84. package/dist/cli/chat.d.ts +14 -0
  85. package/dist/cli/chat.js +220 -0
  86. package/dist/cli/cron.d.ts +17 -0
  87. package/dist/cli/cron.js +552 -0
  88. package/dist/cli/dashboard.d.ts +15 -0
  89. package/dist/cli/dashboard.js +17677 -0
  90. package/dist/cli/index.d.ts +3 -0
  91. package/dist/cli/index.js +2474 -0
  92. package/dist/cli/routes/delegations.d.ts +19 -0
  93. package/dist/cli/routes/delegations.js +154 -0
  94. package/dist/cli/routes/digest.d.ts +17 -0
  95. package/dist/cli/routes/digest.js +375 -0
  96. package/dist/cli/routes/goals.d.ts +14 -0
  97. package/dist/cli/routes/goals.js +258 -0
  98. package/dist/cli/routes/workflows.d.ts +18 -0
  99. package/dist/cli/routes/workflows.js +97 -0
  100. package/dist/cli/setup.d.ts +8 -0
  101. package/dist/cli/setup.js +619 -0
  102. package/dist/cli/tunnel.d.ts +35 -0
  103. package/dist/cli/tunnel.js +141 -0
  104. package/dist/config.d.ts +145 -0
  105. package/dist/config.js +278 -0
  106. package/dist/events/bus.d.ts +43 -0
  107. package/dist/events/bus.js +136 -0
  108. package/dist/gateway/cron-scheduler.d.ts +166 -0
  109. package/dist/gateway/cron-scheduler.js +1767 -0
  110. package/dist/gateway/delivery-queue.d.ts +30 -0
  111. package/dist/gateway/delivery-queue.js +110 -0
  112. package/dist/gateway/heartbeat-scheduler.d.ts +99 -0
  113. package/dist/gateway/heartbeat-scheduler.js +1298 -0
  114. package/dist/gateway/heartbeat.d.ts +3 -0
  115. package/dist/gateway/heartbeat.js +3 -0
  116. package/dist/gateway/lanes.d.ts +24 -0
  117. package/dist/gateway/lanes.js +76 -0
  118. package/dist/gateway/notifications.d.ts +29 -0
  119. package/dist/gateway/notifications.js +75 -0
  120. package/dist/gateway/router.d.ts +210 -0
  121. package/dist/gateway/router.js +1330 -0
  122. package/dist/index.d.ts +12 -0
  123. package/dist/index.js +1015 -0
  124. package/dist/memory/chunker.d.ts +28 -0
  125. package/dist/memory/chunker.js +226 -0
  126. package/dist/memory/consolidation.d.ts +44 -0
  127. package/dist/memory/consolidation.js +171 -0
  128. package/dist/memory/context-assembler.d.ts +50 -0
  129. package/dist/memory/context-assembler.js +149 -0
  130. package/dist/memory/embeddings.d.ts +38 -0
  131. package/dist/memory/embeddings.js +180 -0
  132. package/dist/memory/graph-store.d.ts +66 -0
  133. package/dist/memory/graph-store.js +613 -0
  134. package/dist/memory/mmr.d.ts +21 -0
  135. package/dist/memory/mmr.js +75 -0
  136. package/dist/memory/search.d.ts +26 -0
  137. package/dist/memory/search.js +67 -0
  138. package/dist/memory/store.d.ts +530 -0
  139. package/dist/memory/store.js +2022 -0
  140. package/dist/security/integrity.d.ts +24 -0
  141. package/dist/security/integrity.js +58 -0
  142. package/dist/security/patterns.d.ts +34 -0
  143. package/dist/security/patterns.js +110 -0
  144. package/dist/security/scanner.d.ts +32 -0
  145. package/dist/security/scanner.js +263 -0
  146. package/dist/tools/admin-tools.d.ts +12 -0
  147. package/dist/tools/admin-tools.js +1278 -0
  148. package/dist/tools/external-tools.d.ts +11 -0
  149. package/dist/tools/external-tools.js +1327 -0
  150. package/dist/tools/goal-tools.d.ts +9 -0
  151. package/dist/tools/goal-tools.js +159 -0
  152. package/dist/tools/mcp-server.d.ts +13 -0
  153. package/dist/tools/mcp-server.js +141 -0
  154. package/dist/tools/memory-tools.d.ts +10 -0
  155. package/dist/tools/memory-tools.js +568 -0
  156. package/dist/tools/session-tools.d.ts +6 -0
  157. package/dist/tools/session-tools.js +146 -0
  158. package/dist/tools/shared.d.ts +216 -0
  159. package/dist/tools/shared.js +340 -0
  160. package/dist/tools/team-tools.d.ts +6 -0
  161. package/dist/tools/team-tools.js +447 -0
  162. package/dist/tools/tool-meta.d.ts +34 -0
  163. package/dist/tools/tool-meta.js +133 -0
  164. package/dist/tools/vault-tools.d.ts +8 -0
  165. package/dist/tools/vault-tools.js +457 -0
  166. package/dist/types.d.ts +716 -0
  167. package/dist/types.js +16 -0
  168. package/dist/vault-migrations/0001-add-execution-framework.d.ts +10 -0
  169. package/dist/vault-migrations/0001-add-execution-framework.js +47 -0
  170. package/dist/vault-migrations/0002-add-agentic-communication.d.ts +12 -0
  171. package/dist/vault-migrations/0002-add-agentic-communication.js +79 -0
  172. package/dist/vault-migrations/0003-update-execution-pipeline-narration.d.ts +11 -0
  173. package/dist/vault-migrations/0003-update-execution-pipeline-narration.js +73 -0
  174. package/dist/vault-migrations/helpers.d.ts +14 -0
  175. package/dist/vault-migrations/helpers.js +44 -0
  176. package/dist/vault-migrations/runner.d.ts +14 -0
  177. package/dist/vault-migrations/runner.js +139 -0
  178. package/dist/vault-migrations/types.d.ts +42 -0
  179. package/dist/vault-migrations/types.js +9 -0
  180. package/install.sh +320 -0
  181. package/package.json +84 -0
  182. package/scripts/postinstall.js +125 -0
  183. package/vault/00-System/AGENTS.md +66 -0
  184. package/vault/00-System/CRON.md +71 -0
  185. package/vault/00-System/HEARTBEAT.md +58 -0
  186. package/vault/00-System/MEMORY.md +16 -0
  187. package/vault/00-System/SOUL.md +96 -0
  188. package/vault/05-Tasks/TASKS.md +19 -0
  189. package/vault/06-Templates/_Daily-Template.md +28 -0
  190. package/vault/06-Templates/_People-Template.md +22 -0
@@ -0,0 +1,1327 @@
1
+ /**
2
+ * Clementine TypeScript — External/Integration MCP tools.
3
+ *
4
+ * rss_fetch, web_search, github_prs, browser_screenshot,
5
+ * outlook_inbox/search/calendar/create_event/find_availability/draft/send/read_email,
6
+ * discord_channel_read/send/send_buttons/create,
7
+ * SDR/CRM tools, Salesforce tools
8
+ */
9
+ import { execSync } from 'node:child_process';
10
+ import { existsSync, readFileSync } from 'node:fs';
11
+ import path from 'node:path';
12
+ import { z } from 'zod';
13
+ import { ACTIVE_AGENT_SLUG, EXTERNAL_CONTENT_TAG, SYSTEM_DIR, env, externalResult, getStore, logger, textResult, } from './shared.js';
14
+ import { getToolDescription } from './tool-meta.js';
15
+ export function registerExternalTools(server) {
16
+ // ── 13. rss_fetch ──────────────────────────────────────────────────────
17
+ server.tool('rss_fetch', getToolDescription('rss_fetch') ?? 'Fetch and parse RSS feeds. Returns recent articles with titles, links, dates, and summaries.', {
18
+ feed_url: z.string().optional().describe('Single RSS feed URL (optional — if omitted, reads from RSS-FEEDS.md)'),
19
+ }, async ({ feed_url }) => {
20
+ const feedsToFetch = [];
21
+ if (feed_url) {
22
+ feedsToFetch.push({ name: 'Custom Feed', url: feed_url });
23
+ }
24
+ else {
25
+ // Read feeds from RSS-FEEDS.md
26
+ const rssConfig = path.join(SYSTEM_DIR, 'RSS-FEEDS.md');
27
+ if (!existsSync(rssConfig)) {
28
+ return textResult('Error: vault/00-System/RSS-FEEDS.md not found.');
29
+ }
30
+ try {
31
+ const matter = await import('gray-matter');
32
+ const parsed = matter.default(readFileSync(rssConfig, 'utf-8'));
33
+ const feeds = (parsed.data?.feeds ?? []);
34
+ for (const feed of feeds) {
35
+ if (feed.enabled !== false) {
36
+ feedsToFetch.push({ name: feed.name ?? 'Unnamed', url: feed.url });
37
+ }
38
+ }
39
+ }
40
+ catch (err) {
41
+ return textResult(`Error reading RSS-FEEDS.md: ${err}`);
42
+ }
43
+ }
44
+ if (!feedsToFetch.length) {
45
+ return textResult('No enabled feeds found in RSS-FEEDS.md.');
46
+ }
47
+ const allResults = [];
48
+ for (const feedInfo of feedsToFetch) {
49
+ try {
50
+ const response = await fetch(feedInfo.url, {
51
+ headers: { 'User-Agent': 'Clementine/1.0' },
52
+ signal: AbortSignal.timeout(15000),
53
+ });
54
+ if (!response.ok) {
55
+ allResults.push(`**${feedInfo.name}** — Error: HTTP ${response.status}`);
56
+ continue;
57
+ }
58
+ const xml = await response.text();
59
+ // Simple XML parsing for RSS/Atom items
60
+ const items = parseRssXml(xml);
61
+ if (!items.length) {
62
+ allResults.push(`**${feedInfo.name}** — No articles found`);
63
+ continue;
64
+ }
65
+ const limited = items.slice(0, 10);
66
+ const lines = [`**${feedInfo.name}** (${limited.length} articles):`];
67
+ for (const item of limited) {
68
+ let line = `- **${item.title}**`;
69
+ if (item.pubDate)
70
+ line += ` (${item.pubDate})`;
71
+ if (item.link)
72
+ line += `\n Link: ${item.link}`;
73
+ if (item.summary)
74
+ line += `\n ${item.summary.slice(0, 200)}`;
75
+ lines.push(line);
76
+ }
77
+ allResults.push(lines.join('\n'));
78
+ }
79
+ catch (err) {
80
+ allResults.push(`**${feedInfo.name}** — Error fetching feed: ${err}`);
81
+ }
82
+ }
83
+ return externalResult(allResults.join('\n\n---\n\n'));
84
+ });
85
+ /** Simple RSS/Atom XML parser (no external dependency). */
86
+ function parseRssXml(xml) {
87
+ const items = [];
88
+ // Try RSS <item> first, then Atom <entry>
89
+ const itemRegex = /<item[\s>]([\s\S]*?)<\/item>/gi;
90
+ const entryRegex = /<entry[\s>]([\s\S]*?)<\/entry>/gi;
91
+ const regex = xml.includes('<item') ? itemRegex : entryRegex;
92
+ let match;
93
+ while ((match = regex.exec(xml)) !== null) {
94
+ const block = match[1];
95
+ const title = extractTag(block, 'title');
96
+ const link = extractTag(block, 'link') || extractAttr(block, 'link', 'href');
97
+ const pubDate = extractTag(block, 'pubDate') || extractTag(block, 'published') || extractTag(block, 'updated');
98
+ const summary = extractTag(block, 'description') || extractTag(block, 'summary') || '';
99
+ // Strip HTML tags from summary
100
+ const cleanSummary = summary.replace(/<[^>]+>/g, '').trim();
101
+ items.push({ title, link, pubDate, summary: cleanSummary });
102
+ }
103
+ return items;
104
+ }
105
+ function extractTag(xml, tag) {
106
+ // Handle CDATA
107
+ const cdataRe = new RegExp(`<${tag}[^>]*><!\\[CDATA\\[([\\s\\S]*?)\\]\\]></${tag}>`, 'i');
108
+ const cdataMatch = cdataRe.exec(xml);
109
+ if (cdataMatch)
110
+ return cdataMatch[1].trim();
111
+ const re = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, 'i');
112
+ const m = re.exec(xml);
113
+ return m ? m[1].trim() : '';
114
+ }
115
+ function extractAttr(xml, tag, attr) {
116
+ const re = new RegExp(`<${tag}[^>]*${attr}="([^"]*)"`, 'i');
117
+ const m = re.exec(xml);
118
+ return m ? m[1] : '';
119
+ }
120
+ // ── 14. web_search ──────────────────────────────────────────────────────
121
+ server.tool('web_search', getToolDescription('web_search') ?? 'Search the web via DuckDuckGo. Returns titles, URLs, and snippets. No API key required.', {
122
+ query: z.string().describe('Search query'),
123
+ max_results: z.number().optional().default(5).describe('Max results (1-10)'),
124
+ }, async ({ query, max_results }) => {
125
+ const encoded = encodeURIComponent(query);
126
+ const url = `https://html.duckduckgo.com/html/?q=${encoded}`;
127
+ const response = await fetch(url, {
128
+ headers: { 'User-Agent': 'Clementine/1.0' },
129
+ signal: AbortSignal.timeout(15000),
130
+ });
131
+ const html = await response.text();
132
+ const results = parseDdgResults(html, Math.min(max_results ?? 5, 10));
133
+ if (!results.length)
134
+ return textResult(`No results found for: ${query}`);
135
+ const formatted = results
136
+ .map((r, i) => `${i + 1}. **${r.title}**\n ${r.url}\n ${r.snippet}`)
137
+ .join('\n\n');
138
+ return externalResult(`Search results for "${query}":\n\n${formatted}`);
139
+ });
140
+ /** Parse DuckDuckGo HTML search results. */
141
+ function parseDdgResults(html, max) {
142
+ const results = [];
143
+ // DDG wraps each result in a <div class="result ..."> with:
144
+ // <a class="result__a" href="...">Title</a>
145
+ // <a class="result__snippet" ...>Snippet text</a>
146
+ const resultBlockRe = /<div[^>]*class="[^"]*result\b[^"]*"[^>]*>([\s\S]*?)<\/div>\s*(?=<div[^>]*class="[^"]*result\b|$)/gi;
147
+ const titleRe = /<a[^>]*class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/i;
148
+ const snippetRe = /<(?:a|span)[^>]*class="result__snippet"[^>]*>([\s\S]*?)<\/(?:a|span)>/i;
149
+ let blockMatch;
150
+ while ((blockMatch = resultBlockRe.exec(html)) !== null && results.length < max) {
151
+ const block = blockMatch[1];
152
+ const titleMatch = titleRe.exec(block);
153
+ if (!titleMatch)
154
+ continue;
155
+ let href = titleMatch[1];
156
+ // DDG proxies URLs through //duckduckgo.com/l/?uddg=<encoded_url>
157
+ if (href.includes('uddg=')) {
158
+ const uddg = new URL(href, 'https://duckduckgo.com').searchParams.get('uddg');
159
+ if (uddg)
160
+ href = uddg;
161
+ }
162
+ const title = titleMatch[2].replace(/<[^>]+>/g, '').trim();
163
+ const snippetMatch = snippetRe.exec(block);
164
+ const snippet = snippetMatch
165
+ ? snippetMatch[1].replace(/<[^>]+>/g, '').trim()
166
+ : '';
167
+ if (title && href) {
168
+ results.push({ title, url: href, snippet });
169
+ }
170
+ }
171
+ return results;
172
+ }
173
+ // ── 15. github_prs ─────────────────────────────────────────────────────
174
+ server.tool('github_prs', getToolDescription('github_prs') ?? 'Check GitHub PRs — review-requested and authored. Read-only.', { _empty: z.string().optional().describe('(no parameters needed)') }, async () => {
175
+ const parts = [];
176
+ try {
177
+ const reviewResult = execSync('gh pr list --search "review-requested:@me"', { encoding: 'utf-8', timeout: 30000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
178
+ parts.push(reviewResult
179
+ ? `**PRs needing your review:**\n${reviewResult}`
180
+ : '**PRs needing your review:** None');
181
+ }
182
+ catch (err) {
183
+ parts.push(`**PRs needing review:** Error — ${err}`);
184
+ }
185
+ try {
186
+ const authorResult = execSync('gh pr list --author "@me"', { encoding: 'utf-8', timeout: 30000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
187
+ parts.push(authorResult
188
+ ? `**Your open PRs:**\n${authorResult}`
189
+ : '**Your open PRs:** None');
190
+ }
191
+ catch (err) {
192
+ parts.push(`**Your open PRs:** Error — ${err}`);
193
+ }
194
+ return textResult(parts.join('\n\n'));
195
+ });
196
+ // ── 15. browser_screenshot ─────────────────────────────────────────────
197
+ server.tool('browser_screenshot', 'Take a screenshot of a URL using a Kernel cloud browser.', {
198
+ url: z.string().describe('URL to screenshot'),
199
+ }, async ({ url }) => {
200
+ try {
201
+ // Verify kernel CLI is available
202
+ execSync('which kernel', { stdio: 'pipe' });
203
+ }
204
+ catch {
205
+ return textResult('kernel CLI not found. Install with: npm i -g @onkernel/cli');
206
+ }
207
+ let browserId = null;
208
+ try {
209
+ // Create browser
210
+ const createOut = execSync(`kernel browsers create --timeout 60 --viewport "1920x1080@25" -o json`, { encoding: 'utf-8', timeout: 30000 });
211
+ const data = JSON.parse(createOut);
212
+ browserId = data.id ?? data.session_id ?? null;
213
+ if (!browserId) {
214
+ return textResult(`No browser ID in response: ${createOut.slice(0, 200)}`);
215
+ }
216
+ // Navigate
217
+ const navCode = `await page.goto("${url.replace(/"/g, '\\"')}", { waitUntil: "domcontentloaded" }); await page.waitForTimeout(3000);`;
218
+ execSync(`kernel browsers playwright execute ${browserId} '${navCode.replace(/'/g, "\\'")}'`, { encoding: 'utf-8', timeout: 60000 });
219
+ // Screenshot
220
+ const tmpPath = path.join((process.env.TMPDIR ?? '/tmp'), `kernel_screenshot_${Date.now()}.png`);
221
+ execSync(`kernel browsers computer screenshot ${browserId} --to "${tmpPath}"`, { encoding: 'utf-8', timeout: 15000 });
222
+ return textResult(`Screenshot saved to: ${tmpPath}`);
223
+ }
224
+ catch (err) {
225
+ return textResult(`Browser screenshot error: ${err}`);
226
+ }
227
+ finally {
228
+ if (browserId) {
229
+ try {
230
+ execSync(`kernel browsers delete ${browserId}`, { timeout: 10000, stdio: 'pipe' });
231
+ }
232
+ catch {
233
+ // ignore cleanup errors
234
+ }
235
+ }
236
+ }
237
+ });
238
+ // ── Microsoft Graph API ────────────────────────────────────────────────
239
+ let graphToken = null;
240
+ async function getGraphToken() {
241
+ const tenantId = env['MS_TENANT_ID'] ?? '';
242
+ const clientId = env['MS_CLIENT_ID'] ?? '';
243
+ const clientSecret = env['MS_CLIENT_SECRET'] ?? '';
244
+ if (!tenantId || !clientId || !clientSecret) {
245
+ throw new Error('Outlook not configured — set MS_TENANT_ID, MS_CLIENT_ID, MS_CLIENT_SECRET in .env');
246
+ }
247
+ // Return cached token if still valid (with 5-min buffer)
248
+ if (graphToken && Date.now() < graphToken.expiresAt - 300_000) {
249
+ return graphToken.accessToken;
250
+ }
251
+ const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
252
+ const body = new URLSearchParams({
253
+ client_id: clientId,
254
+ client_secret: clientSecret,
255
+ scope: 'https://graph.microsoft.com/.default',
256
+ grant_type: 'client_credentials',
257
+ });
258
+ const res = await fetch(tokenUrl, { method: 'POST', body });
259
+ if (!res.ok) {
260
+ const text = await res.text();
261
+ throw new Error(`Graph token request failed (${res.status}): ${text}`);
262
+ }
263
+ const data = (await res.json());
264
+ graphToken = {
265
+ accessToken: data.access_token,
266
+ expiresAt: Date.now() + data.expires_in * 1000,
267
+ };
268
+ return graphToken.accessToken;
269
+ }
270
+ async function graphGet(endpoint) {
271
+ const token = await getGraphToken();
272
+ const res = await fetch(`https://graph.microsoft.com/v1.0${endpoint}`, {
273
+ headers: { Authorization: `Bearer ${token}` },
274
+ });
275
+ if (!res.ok) {
276
+ const text = await res.text();
277
+ throw new Error(`Graph API ${res.status}: ${text}`);
278
+ }
279
+ return res.json();
280
+ }
281
+ async function graphPost(endpoint, body) {
282
+ const token = await getGraphToken();
283
+ const res = await fetch(`https://graph.microsoft.com/v1.0${endpoint}`, {
284
+ method: 'POST',
285
+ headers: {
286
+ Authorization: `Bearer ${token}`,
287
+ 'Content-Type': 'application/json',
288
+ },
289
+ body: JSON.stringify(body),
290
+ });
291
+ if (!res.ok) {
292
+ const text = await res.text();
293
+ throw new Error(`Graph API ${res.status}: ${text}`);
294
+ }
295
+ // sendMail returns 202 with no body
296
+ if (res.status === 202)
297
+ return { success: true };
298
+ return res.json();
299
+ }
300
+ // ── 17. outlook_inbox ───────────────────────────────────────────────────
301
+ server.tool('outlook_inbox', getToolDescription('outlook_inbox') ?? 'Read recent emails from the Outlook inbox. Returns sender, subject, date, and preview.', {
302
+ count: z.number().optional().default(10).describe('Number of emails to fetch (max 25)'),
303
+ unread_only: z.boolean().optional().default(false).describe('Only return unread emails'),
304
+ }, async ({ count, unread_only }) => {
305
+ const userEmail = env['MS_USER_EMAIL'] ?? '';
306
+ const limit = Math.min(count, 25);
307
+ const filter = unread_only ? '&$filter=isRead eq false' : '';
308
+ const data = await graphGet(`/users/${userEmail}/mailFolders/inbox/messages?$top=${limit}&$select=from,subject,receivedDateTime,bodyPreview,isRead,hasAttachments&$orderby=receivedDateTime desc${filter}`);
309
+ const emails = (data.value ?? []).map((m) => ({
310
+ id: m.id,
311
+ from: m.from?.emailAddress?.name ?? 'unknown',
312
+ from_email: m.from?.emailAddress?.address ?? 'unknown',
313
+ subject: m.subject ?? '(no subject)',
314
+ date: m.receivedDateTime,
315
+ preview: (m.bodyPreview ?? '').slice(0, 200),
316
+ unread: !m.isRead,
317
+ hasAttachments: m.hasAttachments ?? false,
318
+ }));
319
+ return externalResult(JSON.stringify(emails, null, 2));
320
+ });
321
+ // ── 18. outlook_search ──────────────────────────────────────────────────
322
+ server.tool('outlook_search', getToolDescription('outlook_search') ?? 'Search emails by keyword. Searches subject, body, and sender.', {
323
+ query: z.string().describe('Search query (keywords, sender name, subject text)'),
324
+ count: z.number().optional().default(10).describe('Max results (max 25)'),
325
+ }, async ({ query, count }) => {
326
+ const userEmail = env['MS_USER_EMAIL'] ?? '';
327
+ const limit = Math.min(count, 25);
328
+ const data = await graphGet(`/users/${userEmail}/messages?$search="${encodeURIComponent(query)}"&$top=${limit}&$select=from,subject,receivedDateTime,bodyPreview,hasAttachments&$orderby=receivedDateTime desc`);
329
+ const emails = (data.value ?? []).map((m) => ({
330
+ id: m.id,
331
+ from: m.from?.emailAddress?.name ?? 'unknown',
332
+ from_email: m.from?.emailAddress?.address ?? 'unknown',
333
+ subject: m.subject ?? '(no subject)',
334
+ date: m.receivedDateTime,
335
+ preview: (m.bodyPreview ?? '').slice(0, 200),
336
+ hasAttachments: m.hasAttachments ?? false,
337
+ }));
338
+ return externalResult(JSON.stringify(emails, null, 2));
339
+ });
340
+ // ── 19. outlook_calendar ────────────────────────────────────────────────
341
+ server.tool('outlook_calendar', 'View upcoming calendar events. Shows title, time, location, and attendees.', {
342
+ days: z.number().optional().default(7).describe('Number of days ahead to look (max 30)'),
343
+ }, async ({ days }) => {
344
+ const userEmail = env['MS_USER_EMAIL'] ?? '';
345
+ const start = new Date().toISOString();
346
+ const end = new Date(Date.now() + Math.min(days, 30) * 86400000).toISOString();
347
+ const data = await graphGet(`/users/${userEmail}/calendarView?startDateTime=${start}&endDateTime=${end}&$select=subject,start,end,location,attendees,isAllDay&$orderby=start/dateTime&$top=50`);
348
+ const events = (data.value ?? []).map((e) => ({
349
+ title: e.subject ?? '(untitled)',
350
+ start: e.start?.dateTime,
351
+ end: e.end?.dateTime,
352
+ allDay: e.isAllDay ?? false,
353
+ location: e.location?.displayName || null,
354
+ attendees: (e.attendees ?? []).map((a) => a.emailAddress?.name ?? a.emailAddress?.address).slice(0, 10),
355
+ }));
356
+ return externalResult(JSON.stringify(events, null, 2));
357
+ });
358
+ // ── 19b. outlook_create_event ────────────────────────────────────────────
359
+ server.tool('outlook_create_event', 'Create a calendar event and send invitations to attendees. REQUIRES owner approval (Tier 3).', {
360
+ subject: z.string().describe('Event title'),
361
+ startDateTime: z.string().describe('Start time in ISO 8601 format (e.g., 2026-03-28T10:00:00)'),
362
+ endDateTime: z.string().describe('End time in ISO 8601 format (e.g., 2026-03-28T10:30:00)'),
363
+ attendees: z.array(z.string()).describe('List of attendee email addresses'),
364
+ body: z.string().optional().describe('Event description/agenda (plain text)'),
365
+ location: z.string().optional().describe('Event location (room name or address)'),
366
+ isOnlineMeeting: z.boolean().optional().default(false).describe('If true, creates a Teams meeting link'),
367
+ timeZone: z.string().optional().describe('IANA timezone for start/end times (default: account timezone)'),
368
+ }, async ({ subject, startDateTime, endDateTime, attendees, body, location, isOnlineMeeting, timeZone }) => {
369
+ const userEmail = env['MS_USER_EMAIL'] ?? '';
370
+ const tz = timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone;
371
+ const event = {
372
+ subject,
373
+ start: { dateTime: startDateTime, timeZone: tz },
374
+ end: { dateTime: endDateTime, timeZone: tz },
375
+ attendees: attendees.map((email) => ({
376
+ emailAddress: { address: email },
377
+ type: 'required',
378
+ })),
379
+ isOnlineMeeting: isOnlineMeeting ?? false,
380
+ };
381
+ if (body) {
382
+ event.body = { contentType: 'Text', content: body };
383
+ }
384
+ if (location) {
385
+ event.location = { displayName: location };
386
+ }
387
+ if (isOnlineMeeting) {
388
+ event.onlineMeetingProvider = 'teamsForBusiness';
389
+ }
390
+ const created = await graphPost(`/users/${userEmail}/events`, event);
391
+ const teamsLink = created.onlineMeeting?.joinUrl ?? null;
392
+ return textResult(`Event created: "${subject}" on ${startDateTime} — ${endDateTime}\n` +
393
+ `Attendees: ${attendees.join(', ')}\n` +
394
+ (teamsLink ? `Teams link: ${teamsLink}\n` : '') +
395
+ `Event ID: ${(created.id ?? '').slice(0, 20)}...`);
396
+ });
397
+ // ── 19c. outlook_find_availability ──────────────────────────────────────
398
+ server.tool('outlook_find_availability', 'Check free/busy availability for the user\'s calendar. Useful for finding open slots to propose meeting times.', {
399
+ startDateTime: z.string().describe('Start of availability window (ISO 8601)'),
400
+ endDateTime: z.string().describe('End of availability window (ISO 8601)'),
401
+ intervalMinutes: z.number().optional().default(30).describe('Slot duration in minutes (default: 30)'),
402
+ }, async ({ startDateTime, endDateTime, intervalMinutes }) => {
403
+ const userEmail = env['MS_USER_EMAIL'] ?? '';
404
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
405
+ const data = await graphPost(`/users/${userEmail}/calendar/getSchedule`, {
406
+ schedules: [userEmail],
407
+ startTime: { dateTime: startDateTime, timeZone: tz },
408
+ endTime: { dateTime: endDateTime, timeZone: tz },
409
+ availabilityViewInterval: intervalMinutes,
410
+ });
411
+ const schedule = data.value?.[0];
412
+ if (!schedule)
413
+ return textResult('Could not retrieve availability.');
414
+ // Parse the availability view string: 0=free, 1=tentative, 2=busy, 3=oof, 4=working elsewhere
415
+ const view = schedule.availabilityView ?? '';
416
+ const slotStart = new Date(startDateTime);
417
+ const slots = [];
418
+ for (let i = 0; i < view.length; i++) {
419
+ const status = view[i];
420
+ const start = new Date(slotStart.getTime() + i * intervalMinutes * 60000);
421
+ const end = new Date(start.getTime() + intervalMinutes * 60000);
422
+ const label = status === '0' ? 'FREE' : status === '1' ? 'TENTATIVE' : status === '2' ? 'BUSY' : status === '3' ? 'OOF' : 'BUSY';
423
+ if (label === 'FREE' || label === 'TENTATIVE') {
424
+ slots.push(`${start.toISOString().slice(11, 16)}–${end.toISOString().slice(11, 16)} ${label}`);
425
+ }
426
+ }
427
+ if (slots.length === 0)
428
+ return textResult('No available slots in the requested window.');
429
+ return textResult(`Available slots (${tz}):\n${slots.join('\n')}`);
430
+ });
431
+ // ── 20. outlook_draft ───────────────────────────────────────────────────
432
+ server.tool('outlook_draft', 'Create a draft email in the Outlook Drafts folder (does NOT send). Use this for cron jobs that prepare emails for owner review.', {
433
+ to: z.string().describe('Recipient email address'),
434
+ subject: z.string().describe('Email subject line'),
435
+ body: z.string().describe('Email body (plain text)'),
436
+ cc: z.string().optional().describe('CC email address (optional)'),
437
+ reply_to_message_id: z.string().optional().describe('Message ID to reply to. If provided, creates a threaded reply draft instead of a new email. The To and Subject are auto-filled from the original message.'),
438
+ }, async ({ to, subject, body, cc, reply_to_message_id }) => {
439
+ // Suppression check — prevent drafting emails to opted-out recipients
440
+ const store = await getStore();
441
+ if (store.isSuppressed(to)) {
442
+ return textResult(`⛔ Cannot draft email to ${to} — address is on the suppression list.`);
443
+ }
444
+ const userEmail = env['MS_USER_EMAIL'] ?? '';
445
+ if (reply_to_message_id) {
446
+ // Create a reply draft — Graph auto-fills To, Subject, and conversation threading
447
+ const replyDraft = await graphPost(`/users/${userEmail}/messages/${reply_to_message_id}/createReply`, { message: { body: { contentType: 'Text', content: body } } });
448
+ const replyTo = replyDraft.toRecipients?.[0]?.emailAddress?.address ?? to;
449
+ const replySubject = replyDraft.subject ?? subject;
450
+ return textResult(`Reply draft created: "${replySubject}" to ${replyTo} (ID: ${replyDraft.id?.slice(0, 20)}...)`);
451
+ }
452
+ // New draft (not a reply)
453
+ const message = {
454
+ subject,
455
+ body: { contentType: 'Text', content: body },
456
+ toRecipients: [{ emailAddress: { address: to } }],
457
+ };
458
+ if (cc) {
459
+ message.ccRecipients = [{ emailAddress: { address: cc } }];
460
+ }
461
+ // POST to /messages (not /sendMail) creates a draft
462
+ const draft = await graphPost(`/users/${userEmail}/messages`, message);
463
+ return textResult(`Draft created: "${subject}" to ${to} (ID: ${draft.id?.slice(0, 20)}...)`);
464
+ });
465
+ // ── 21. outlook_send ────────────────────────────────────────────────────
466
+ server.tool('outlook_send', 'Send an email from your Outlook account. REQUIRES owner approval (Tier 3).', {
467
+ to: z.string().describe('Recipient email address'),
468
+ subject: z.string().describe('Email subject line'),
469
+ body: z.string().describe('Email body (plain text)'),
470
+ cc: z.string().optional().describe('CC email address (optional)'),
471
+ }, async ({ to, subject, body, cc }) => {
472
+ // Suppression check — prevent sending to opted-out recipients
473
+ const store = await getStore();
474
+ if (store.isSuppressed(to)) {
475
+ return textResult(`⛔ Cannot send email to ${to} — address is on the suppression list.`);
476
+ }
477
+ const userEmail = env['MS_USER_EMAIL'] ?? '';
478
+ const message = {
479
+ subject,
480
+ body: { contentType: 'Text', content: body },
481
+ toRecipients: [{ emailAddress: { address: to } }],
482
+ };
483
+ if (cc) {
484
+ message.ccRecipients = [{ emailAddress: { address: cc } }];
485
+ }
486
+ await graphPost(`/users/${userEmail}/sendMail`, { message, saveToSentItems: true });
487
+ // Log the send for daily cap tracking and audit
488
+ const agentSlug = ACTIVE_AGENT_SLUG ?? 'clementine';
489
+ store.logSend({ agentSlug, recipient: to, subject });
490
+ return textResult(`Email sent to ${to}: "${subject}"`);
491
+ });
492
+ // ── 22. outlook_read_email ───────────────────────────────────────────────
493
+ server.tool('outlook_read_email', 'Read a full email by ID, including body and attachment list. Use this to inspect email attachments after finding emails with outlook_inbox or outlook_search.', {
494
+ messageId: z.string().describe('The email message ID (from outlook_inbox or outlook_search)'),
495
+ }, async ({ messageId }) => {
496
+ const userEmail = env['MS_USER_EMAIL'] ?? '';
497
+ const data = await graphGet(`/users/${userEmail}/messages/${messageId}?$expand=attachments&$select=subject,from,body,receivedDateTime,hasAttachments`);
498
+ // Format attachment info
499
+ const attachments = (data.attachments ?? []).map((att) => ({
500
+ name: att.name,
501
+ contentType: att.contentType,
502
+ size: att.size,
503
+ isImage: att.contentType?.startsWith('image/') ?? false,
504
+ }));
505
+ // Strip HTML tags from body
506
+ const bodyText = (data.body?.content ?? '(no body)').replace(/<[^>]*>/g, '');
507
+ const result = {
508
+ subject: data.subject ?? '(no subject)',
509
+ from: data.from?.emailAddress?.address ?? 'unknown',
510
+ receivedAt: data.receivedDateTime,
511
+ body: bodyText.slice(0, 3000),
512
+ attachments: attachments.length > 0
513
+ ? attachments.map((a) => `- ${a.name} (${a.contentType}, ${Math.round(a.size / 1024)}KB)${a.isImage ? ' [image — use analyze_image to view]' : ''}`).join('\n')
514
+ : '(none)',
515
+ };
516
+ return externalResult(JSON.stringify(result, null, 2));
517
+ });
518
+ // ── Discord Channel Read ────────────────────────────────────────────────
519
+ server.tool('discord_channel_read', 'Read recent messages from a Discord text channel. Use to monitor agent output, review drafts, or audit channel activity.', {
520
+ channel_id: z.string().describe('Discord channel ID to read from'),
521
+ limit: z.number().min(1).max(100).optional().describe('Number of messages to fetch (default: 20, max: 100)'),
522
+ before: z.string().optional().describe('Fetch messages before this message ID (for pagination)'),
523
+ }, async ({ channel_id, limit, before }) => {
524
+ const token = env['DISCORD_TOKEN'] ?? '';
525
+ if (!token)
526
+ throw new Error('DISCORD_TOKEN not configured');
527
+ if (!channel_id)
528
+ throw new Error('channel_id is required');
529
+ const params = new URLSearchParams();
530
+ params.set('limit', String(limit ?? 20));
531
+ if (before)
532
+ params.set('before', before);
533
+ const res = await fetch(`https://discord.com/api/v10/channels/${channel_id}/messages?${params}`, { headers: { Authorization: `Bot ${token}` } });
534
+ if (!res.ok) {
535
+ const errText = await res.text();
536
+ throw new Error(`Discord API ${res.status}: ${errText}`);
537
+ }
538
+ const messages = (await res.json());
539
+ if (messages.length === 0) {
540
+ return textResult('No messages found in this channel.');
541
+ }
542
+ // Format messages newest-first → reverse to chronological order
543
+ const formatted = messages.reverse().map((m) => {
544
+ const time = new Date(m.timestamp).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
545
+ const tag = m.author.bot ? ` [BOT]` : '';
546
+ let text = `[${time}] ${m.author.username}${tag}: ${m.content}`;
547
+ // Include embed content (team messages, rich content)
548
+ if (m.embeds?.length) {
549
+ for (const embed of m.embeds) {
550
+ if (embed.title)
551
+ text += `\n Embed: ${embed.title}`;
552
+ if (embed.description)
553
+ text += `\n ${embed.description.slice(0, 500)}`;
554
+ }
555
+ }
556
+ return text;
557
+ });
558
+ return textResult(`Channel messages (${messages.length}):\n\n${formatted.join('\n\n')}` +
559
+ (messages.length === (limit ?? 20) ? `\n\n(Use before: "${messages[0].id}" to load older messages)` : ''));
560
+ });
561
+ // ── Discord Channel Send ────────────────────────────────────────────────
562
+ server.tool('discord_channel_send', 'Send a message to a Discord text channel by ID. For posting digests, summaries, or alerts to server channels.', {
563
+ channel_id: z.string().describe('Discord channel ID to post to'),
564
+ message: z.string().describe('Message content (Discord markdown, max 2000 chars per chunk)'),
565
+ }, async ({ channel_id, message }) => {
566
+ const token = env['DISCORD_TOKEN'] ?? '';
567
+ if (!token)
568
+ throw new Error('DISCORD_TOKEN not configured');
569
+ if (!channel_id)
570
+ throw new Error('channel_id is required');
571
+ const chunks = [];
572
+ let remaining = message;
573
+ while (remaining.length > 0) {
574
+ if (remaining.length <= 1900) {
575
+ chunks.push(remaining);
576
+ break;
577
+ }
578
+ let splitAt = remaining.lastIndexOf('\n', 1900);
579
+ if (splitAt === -1)
580
+ splitAt = 1900;
581
+ chunks.push(remaining.slice(0, splitAt));
582
+ remaining = remaining.slice(splitAt).replace(/^\n+/, '');
583
+ }
584
+ for (const chunk of chunks) {
585
+ const res = await fetch(`https://discord.com/api/v10/channels/${channel_id}/messages`, {
586
+ method: 'POST',
587
+ headers: { Authorization: `Bot ${token}`, 'Content-Type': 'application/json' },
588
+ body: JSON.stringify({ content: chunk }),
589
+ });
590
+ if (!res.ok) {
591
+ const errText = await res.text();
592
+ throw new Error(`Discord API ${res.status}: ${errText}`);
593
+ }
594
+ }
595
+ return textResult(`Message posted to channel ${channel_id} (${chunks.length} chunk${chunks.length > 1 ? 's' : ''})`);
596
+ });
597
+ // ── Discord Channel Send with Buttons ──────────────────────────────────
598
+ server.tool('discord_channel_send_buttons', 'Send a message to a Discord channel with approve/deny action buttons. Returns the message ID for tracking.', {
599
+ channel_id: z.string().describe('Discord channel ID to post to'),
600
+ message: z.string().describe('Message content (Discord markdown)'),
601
+ approve_label: z.string().optional().describe('Label for approve button (default: Approve)'),
602
+ deny_label: z.string().optional().describe('Label for deny button (default: Deny)'),
603
+ custom_id_prefix: z.string().optional().describe('Prefix for button custom IDs (default: audit). Buttons will be {prefix}_approve and {prefix}_deny'),
604
+ }, async ({ channel_id, message, approve_label, deny_label, custom_id_prefix }) => {
605
+ const token = env['DISCORD_TOKEN'] ?? '';
606
+ if (!token)
607
+ throw new Error('DISCORD_TOKEN not configured');
608
+ if (!channel_id)
609
+ throw new Error('channel_id is required');
610
+ const prefix = custom_id_prefix ?? 'audit';
611
+ const payload = {
612
+ content: message.slice(0, 2000),
613
+ components: [
614
+ {
615
+ type: 1, // ACTION_ROW
616
+ components: [
617
+ {
618
+ type: 2, // BUTTON
619
+ style: 3, // SUCCESS (green)
620
+ label: approve_label ?? '✅ Approve',
621
+ custom_id: `${prefix}_approve`,
622
+ },
623
+ {
624
+ type: 2, // BUTTON
625
+ style: 4, // DANGER (red)
626
+ label: deny_label ?? '❌ Deny',
627
+ custom_id: `${prefix}_deny`,
628
+ },
629
+ ],
630
+ },
631
+ ],
632
+ };
633
+ const res = await fetch(`https://discord.com/api/v10/channels/${channel_id}/messages`, {
634
+ method: 'POST',
635
+ headers: { Authorization: `Bot ${token}`, 'Content-Type': 'application/json' },
636
+ body: JSON.stringify(payload),
637
+ });
638
+ if (!res.ok) {
639
+ const errText = await res.text();
640
+ throw new Error(`Discord API ${res.status}: ${errText}`);
641
+ }
642
+ const msg = (await res.json());
643
+ return textResult(`Message with buttons posted to channel ${channel_id} (message ID: ${msg.id})`);
644
+ });
645
+ // ── Discord Channel Create ─────────────────────────────────────────────
646
+ server.tool('discord_channel_create', 'Create a new Discord text channel in a guild/server. Requires Manage Channels permission.', {
647
+ guild_id: z.string().describe('Discord guild/server ID'),
648
+ channel_name: z.string().describe('Name for the new channel (lowercase, hyphens)'),
649
+ topic: z.string().optional().describe('Optional channel topic/description'),
650
+ category_id: z.string().optional().describe('Optional category ID to place the channel under'),
651
+ }, async ({ guild_id, channel_name, topic, category_id }) => {
652
+ const token = env['DISCORD_TOKEN'] ?? '';
653
+ if (!token)
654
+ throw new Error('DISCORD_TOKEN not configured');
655
+ const payload = {
656
+ name: channel_name,
657
+ type: 0, // GUILD_TEXT
658
+ };
659
+ if (topic)
660
+ payload.topic = topic;
661
+ if (category_id)
662
+ payload.parent_id = category_id;
663
+ const res = await fetch(`https://discord.com/api/v10/guilds/${guild_id}/channels`, {
664
+ method: 'POST',
665
+ headers: { Authorization: `Bot ${token}`, 'Content-Type': 'application/json' },
666
+ body: JSON.stringify(payload),
667
+ });
668
+ if (!res.ok) {
669
+ const errText = await res.text();
670
+ throw new Error(`Discord API ${res.status}: ${errText}`);
671
+ }
672
+ const channel = (await res.json());
673
+ return textResult(`Created channel #${channel.name} (ID: ${channel.id}) in guild ${guild_id}`);
674
+ });
675
+ // ── SDR Operational Tools ────────────────────────────────────────────────
676
+ server.tool('lead_upsert', 'Create or update a lead/prospect record. Updates existing if email matches.', {
677
+ email: z.string().describe('Lead email address (unique identifier)'),
678
+ name: z.string().describe('Lead full name'),
679
+ company: z.string().optional().describe('Company name'),
680
+ title: z.string().optional().describe('Job title'),
681
+ status: z.enum(['new', 'contacted', 'replied', 'qualified', 'meeting_booked', 'won', 'lost', 'opted_out']).optional().describe('Lead status'),
682
+ source: z.string().optional().describe('Lead source (e.g., inbound, outreach, referral)'),
683
+ sfId: z.string().optional().describe('Salesforce lead/contact ID'),
684
+ metadata: z.record(z.string(), z.unknown()).optional().describe('Additional metadata as key-value pairs'),
685
+ }, async ({ email, name, company, title, status, source, sfId, metadata }) => {
686
+ const store = await getStore();
687
+ const agentSlug = ACTIVE_AGENT_SLUG ?? 'clementine';
688
+ const result = store.upsertLead({
689
+ agentSlug, email, name, company, title, status, source, sfId, metadata,
690
+ });
691
+ return textResult(result.created
692
+ ? `Lead created: ${name} <${email}> (ID: ${result.id})`
693
+ : `Lead updated: ${name} <${email}> (ID: ${result.id})`);
694
+ });
695
+ server.tool('lead_search', 'Search leads/prospects by status, company, or keyword. Returns structured lead records.', {
696
+ status: z.enum(['new', 'contacted', 'replied', 'qualified', 'meeting_booked', 'won', 'lost', 'opted_out']).optional().describe('Filter by lead status'),
697
+ company: z.string().optional().describe('Filter by company name (partial match)'),
698
+ query: z.string().optional().describe('Search across name, email, company'),
699
+ limit: z.number().optional().default(20).describe('Max results (default 20)'),
700
+ }, async ({ status, company, query, limit }) => {
701
+ const store = await getStore();
702
+ const agentSlug = ACTIVE_AGENT_SLUG ?? undefined;
703
+ const results = store.searchLeads({ agentSlug, status, company, query, limit });
704
+ if (results.length === 0)
705
+ return textResult('No leads found matching criteria.');
706
+ return textResult(JSON.stringify(results, null, 2));
707
+ });
708
+ server.tool('sequence_enroll', 'Enroll a lead in an outbound email sequence/cadence.', {
709
+ leadId: z.number().describe('Lead ID to enroll'),
710
+ sequenceName: z.string().describe('Name of the sequence (e.g., "intro-5step")'),
711
+ nextStepDueAt: z.string().optional().describe('ISO datetime for first step (default: now)'),
712
+ }, async ({ leadId, sequenceName, nextStepDueAt }) => {
713
+ const store = await getStore();
714
+ const lead = store.getLeadById(leadId);
715
+ if (!lead)
716
+ return textResult(`Error: Lead ID ${leadId} not found.`);
717
+ const id = store.enrollSequence({ leadId, sequenceName, nextStepDueAt });
718
+ return textResult(`Enrolled lead ${lead.name} (${lead.email}) in sequence "${sequenceName}" (enrollment ID: ${id})`);
719
+ });
720
+ server.tool('sequence_advance', 'Advance a sequence enrollment to the next step or update its status.', {
721
+ enrollmentId: z.number().describe('Sequence enrollment ID'),
722
+ currentStep: z.number().optional().describe('Set current step number'),
723
+ status: z.enum(['active', 'paused', 'replied', 'completed', 'opted_out']).optional().describe('Update enrollment status'),
724
+ nextStepDueAt: z.string().optional().describe('ISO datetime for next step (null to clear)'),
725
+ }, async ({ enrollmentId, currentStep, status, nextStepDueAt }) => {
726
+ const store = await getStore();
727
+ store.advanceSequence(enrollmentId, {
728
+ currentStep, status, nextStepDueAt: nextStepDueAt ?? undefined,
729
+ });
730
+ const updates = [
731
+ currentStep !== undefined ? `step → ${currentStep}` : null,
732
+ status ? `status → ${status}` : null,
733
+ nextStepDueAt ? `next due → ${nextStepDueAt}` : null,
734
+ ].filter(Boolean).join(', ');
735
+ return textResult(`Enrollment ${enrollmentId} updated: ${updates}`);
736
+ });
737
+ server.tool('sequence_due', 'Get all sequence enrollments with steps due now (for cron processing).', { _empty: z.string().optional().describe('(no parameters needed)') }, async () => {
738
+ const store = await getStore();
739
+ const agentSlug = ACTIVE_AGENT_SLUG ?? undefined;
740
+ const due = store.getDueSequences(agentSlug);
741
+ if (due.length === 0)
742
+ return textResult('No sequence steps due right now.');
743
+ return textResult(`${due.length} due sequence step(s):\n` + JSON.stringify(due, null, 2));
744
+ });
745
+ server.tool('activity_log', 'Record an SDR activity (email sent, meeting booked, call, note, etc.).', {
746
+ type: z.enum(['email_sent', 'email_received', 'meeting_booked', 'call', 'note', 'status_change']).describe('Activity type'),
747
+ leadId: z.number().optional().describe('Lead ID this activity relates to'),
748
+ subject: z.string().optional().describe('Activity subject/title'),
749
+ detail: z.string().optional().describe('Activity details or notes'),
750
+ templateUsed: z.string().optional().describe('Email template used (if applicable)'),
751
+ }, async ({ type, leadId, subject, detail, templateUsed }) => {
752
+ const store = await getStore();
753
+ const agentSlug = ACTIVE_AGENT_SLUG ?? 'clementine';
754
+ const id = store.logActivity({ leadId, agentSlug, type, subject, detail, templateUsed });
755
+ return textResult(`Activity logged: ${type}${subject ? ` — "${subject}"` : ''} (ID: ${id})`);
756
+ });
757
+ server.tool('activity_history', 'Get activity history for a lead or agent.', {
758
+ leadId: z.number().optional().describe('Filter by lead ID'),
759
+ type: z.enum(['email_sent', 'email_received', 'meeting_booked', 'call', 'note', 'status_change']).optional().describe('Filter by activity type'),
760
+ limit: z.number().optional().default(20).describe('Max results'),
761
+ }, async ({ leadId, type, limit }) => {
762
+ const store = await getStore();
763
+ const agentSlug = ACTIVE_AGENT_SLUG ?? undefined;
764
+ const results = store.getActivities({ leadId, agentSlug, type, limit });
765
+ if (results.length === 0)
766
+ return textResult('No activities found.');
767
+ return textResult(JSON.stringify(results, null, 2));
768
+ });
769
+ server.tool('suppression_check', 'Check if an email address is on the suppression (do-not-contact) list.', {
770
+ email: z.string().describe('Email address to check'),
771
+ }, async ({ email }) => {
772
+ const store = await getStore();
773
+ const suppressed = store.isSuppressed(email);
774
+ return textResult(suppressed
775
+ ? `⛔ ${email} is SUPPRESSED — do not contact.`
776
+ : `✓ ${email} is not on the suppression list.`);
777
+ });
778
+ server.tool('suppression_add', 'Add an email to the suppression (do-not-contact) list.', {
779
+ email: z.string().describe('Email address to suppress'),
780
+ reason: z.enum(['unsubscribe', 'bounce', 'manual', 'complaint']).describe('Reason for suppression'),
781
+ }, async ({ email, reason }) => {
782
+ const store = await getStore();
783
+ const agentSlug = ACTIVE_AGENT_SLUG ?? 'manual';
784
+ store.addSuppression(email, reason, agentSlug);
785
+ return textResult(`Added ${email} to suppression list (reason: ${reason}).`);
786
+ });
787
+ server.tool('lead_import', 'Bulk import leads from CSV-style data. Each line: name,email,company,title. Skips duplicates by email.', {
788
+ data: z.string().describe('CSV data — one lead per line: name,email,company,title. First line can be a header (auto-detected).'),
789
+ source: z.string().optional().default('import').describe('Lead source tag (e.g., "csv-import", "list-purchase")'),
790
+ sequenceName: z.string().optional().describe('If provided, auto-enroll each imported lead in this sequence'),
791
+ }, async ({ data, source, sequenceName }) => {
792
+ const store = await getStore();
793
+ const agentSlug = ACTIVE_AGENT_SLUG ?? 'clementine';
794
+ const lines = data.trim().split('\n').filter(Boolean);
795
+ if (lines.length === 0)
796
+ return textResult('No data to import.');
797
+ // Detect and skip header row
798
+ const firstLine = lines[0].toLowerCase();
799
+ const startIdx = (firstLine.includes('name') && firstLine.includes('email')) ? 1 : 0;
800
+ let created = 0;
801
+ let skipped = 0;
802
+ let enrolled = 0;
803
+ const errors = [];
804
+ for (let i = startIdx; i < lines.length; i++) {
805
+ const parts = lines[i].split(',').map(s => s.trim().replace(/^"|"$/g, ''));
806
+ if (parts.length < 2) {
807
+ errors.push(`Line ${i + 1}: not enough fields`);
808
+ continue;
809
+ }
810
+ const [name, email, company, title] = parts;
811
+ if (!name || !email || !email.includes('@')) {
812
+ errors.push(`Line ${i + 1}: invalid name or email`);
813
+ continue;
814
+ }
815
+ try {
816
+ const result = store.upsertLead({
817
+ agentSlug, email, name, company: company || undefined, title: title || undefined,
818
+ source, status: 'new',
819
+ });
820
+ if (result.created) {
821
+ created++;
822
+ // Auto-enroll in sequence if specified
823
+ if (sequenceName) {
824
+ const nextDue = new Date(Date.now() + 60000).toISOString(); // due in 1 min
825
+ store.enrollSequence({ leadId: result.id, sequenceName, nextStepDueAt: nextDue });
826
+ enrolled++;
827
+ }
828
+ }
829
+ else {
830
+ skipped++;
831
+ }
832
+ }
833
+ catch (e) {
834
+ errors.push(`Line ${i + 1}: ${String(e)}`);
835
+ }
836
+ }
837
+ let report = `Import complete: ${created} created, ${skipped} skipped (duplicate)`;
838
+ if (enrolled > 0)
839
+ report += `, ${enrolled} enrolled in "${sequenceName}"`;
840
+ if (errors.length > 0)
841
+ report += `\n\nErrors (${errors.length}):\n${errors.slice(0, 10).join('\n')}`;
842
+ return textResult(report);
843
+ });
844
+ server.tool('approval_queue_add', 'Queue an action for human approval via the dashboard. Use this when your send policy requires approval, or when you want a human to review before executing.', {
845
+ actionType: z.enum(['email_send', 'sequence_start', 'escalation']).describe('Type of action being queued'),
846
+ summary: z.string().describe('Brief description shown in the approval queue (e.g., "Send intro email to jane@acme.com")'),
847
+ detail: z.record(z.string(), z.unknown()).optional().describe('Full action payload — for email_send: {to, subject, body, cc?, leadId?}'),
848
+ }, async ({ actionType, summary, detail }) => {
849
+ const store = await getStore();
850
+ const agentSlug = ACTIVE_AGENT_SLUG ?? 'clementine';
851
+ const id = store.addApproval({ agentSlug, actionType, summary, detail });
852
+ return textResult(`Queued for approval (ID: ${id}): ${summary}\nA human can approve or reject this via the dashboard.`);
853
+ });
854
+ // ── Salesforce REST API ──────────────────────────────────────────────────
855
+ let sfToken = null;
856
+ function sfConfigured() {
857
+ return Boolean(env['SF_INSTANCE_URL'] && env['SF_CLIENT_ID'] && env['SF_CLIENT_SECRET']);
858
+ }
859
+ async function getSfToken() {
860
+ const instanceUrl = env['SF_INSTANCE_URL'] ?? '';
861
+ const clientId = env['SF_CLIENT_ID'] ?? '';
862
+ const clientSecret = env['SF_CLIENT_SECRET'] ?? '';
863
+ const username = env['SF_USERNAME'] ?? '';
864
+ const password = env['SF_PASSWORD'] ?? '';
865
+ if (!instanceUrl || !clientId || !clientSecret) {
866
+ throw new Error('Salesforce not configured — set SF_INSTANCE_URL, SF_CLIENT_ID, SF_CLIENT_SECRET, SF_USERNAME, SF_PASSWORD in .env');
867
+ }
868
+ if (sfToken && Date.now() < sfToken.expiresAt - 300_000) {
869
+ return { accessToken: sfToken.accessToken, instanceUrl: sfToken.instanceUrl };
870
+ }
871
+ // Sandbox detection
872
+ const loginHost = instanceUrl.includes('.sandbox.') || instanceUrl.includes('--')
873
+ ? 'https://test.salesforce.com' : 'https://login.salesforce.com';
874
+ const body = new URLSearchParams({
875
+ grant_type: 'password',
876
+ client_id: clientId,
877
+ client_secret: clientSecret,
878
+ username,
879
+ password,
880
+ });
881
+ const res = await fetch(`${loginHost}/services/oauth2/token`, { method: 'POST', body });
882
+ if (!res.ok) {
883
+ const text = await res.text();
884
+ throw new Error(`Salesforce token request failed (${res.status}): ${text}`);
885
+ }
886
+ const data = (await res.json());
887
+ sfToken = {
888
+ accessToken: data.access_token,
889
+ instanceUrl: data.instance_url,
890
+ expiresAt: Date.now() + 7200_000, // SF tokens typically valid ~2 hours
891
+ };
892
+ return { accessToken: sfToken.accessToken, instanceUrl: sfToken.instanceUrl };
893
+ }
894
+ const SF_API_VERSION = env['SF_API_VERSION'] || 'v62.0';
895
+ async function sfRequest(method, endpoint, body, retry = true) {
896
+ const { accessToken, instanceUrl } = await getSfToken();
897
+ const url = `${instanceUrl}/services/data/${SF_API_VERSION}${endpoint}`;
898
+ const headers = { Authorization: `Bearer ${accessToken}` };
899
+ const opts = { method, headers };
900
+ if (body) {
901
+ headers['Content-Type'] = 'application/json';
902
+ opts.body = JSON.stringify(body);
903
+ }
904
+ const res = await fetch(url, opts);
905
+ // Parse API usage from Sforce-Limit-Info header
906
+ const limitInfo = res.headers.get('Sforce-Limit-Info');
907
+ if (limitInfo) {
908
+ const match = limitInfo.match(/api-usage=(\d+)\/(\d+)/);
909
+ if (match) {
910
+ const [, used, total] = match;
911
+ const pct = (Number(used) / Number(total)) * 100;
912
+ if (pct >= 80)
913
+ logger.warn(`Salesforce API usage at ${pct.toFixed(0)}% (${used}/${total})`);
914
+ }
915
+ }
916
+ // Retry on 401 (expired token)
917
+ if (res.status === 401 && retry) {
918
+ sfToken = null;
919
+ return sfRequest(method, endpoint, body, false);
920
+ }
921
+ if (!res.ok) {
922
+ const text = await res.text();
923
+ throw new Error(`Salesforce ${method} ${endpoint} failed (${res.status}): ${text}`);
924
+ }
925
+ // 204 No Content (PATCH success)
926
+ if (res.status === 204)
927
+ return { success: true };
928
+ return res.json();
929
+ }
930
+ async function sfGet(endpoint) { return sfRequest('GET', endpoint); }
931
+ async function sfPost(endpoint, body) { return sfRequest('POST', endpoint, body); }
932
+ async function sfPatch(endpoint, body) { return sfRequest('PATCH', endpoint, body); }
933
+ async function sfQuery(soql) {
934
+ return sfGet(`/query?q=${encodeURIComponent(soql)}`);
935
+ }
936
+ // Status mapping: local → Salesforce
937
+ const LOCAL_TO_SF_STATUS = {
938
+ 'new': 'Open - Not Contacted',
939
+ 'contacted': 'Working - Contacted',
940
+ 'replied': 'Working - Contacted',
941
+ 'qualified': 'Qualified',
942
+ 'meeting_booked': 'Qualified',
943
+ 'won': 'Closed - Converted',
944
+ 'lost': 'Closed - Not Converted',
945
+ 'opted_out': 'Closed - Not Converted',
946
+ };
947
+ // Status mapping: Salesforce → local
948
+ const SF_TO_LOCAL_STATUS = {
949
+ 'Open - Not Contacted': 'new',
950
+ 'Working - Contacted': 'contacted',
951
+ 'Qualified': 'qualified',
952
+ 'Closed - Converted': 'won',
953
+ 'Closed - Not Converted': 'lost',
954
+ };
955
+ function splitName(fullName) {
956
+ const parts = fullName.trim().split(/\s+/);
957
+ if (parts.length === 1)
958
+ return { FirstName: '', LastName: parts[0] };
959
+ const LastName = parts.pop();
960
+ return { FirstName: parts.join(' '), LastName };
961
+ }
962
+ function joinName(first, last) {
963
+ return [first, last].filter(Boolean).join(' ') || 'Unknown';
964
+ }
965
+ // ── sf_lead_push ─────────────────────────────────────────────────────────
966
+ server.tool('sf_lead_push', 'Push a local lead to Salesforce. Creates a new SF Lead or updates existing if the lead already has a Salesforce ID.', {
967
+ leadId: z.number().describe('Local lead ID to push to Salesforce'),
968
+ }, async ({ leadId }) => {
969
+ if (!sfConfigured())
970
+ return textResult('Salesforce not configured — set SF_INSTANCE_URL, SF_CLIENT_ID, SF_CLIENT_SECRET, SF_USERNAME, SF_PASSWORD in .env');
971
+ const store = await getStore();
972
+ const lead = store.getLeadById(leadId);
973
+ if (!lead)
974
+ return textResult(`Lead ID ${leadId} not found`);
975
+ const { FirstName, LastName } = splitName(String(lead.name ?? ''));
976
+ const sfData = {
977
+ FirstName,
978
+ LastName,
979
+ Email: lead.email,
980
+ Company: lead.company || '[Unknown]',
981
+ Title: lead.title || undefined,
982
+ Status: LOCAL_TO_SF_STATUS[String(lead.status ?? 'new')] || 'Open - Not Contacted',
983
+ LeadSource: lead.source || undefined,
984
+ };
985
+ // Remove undefined values
986
+ for (const k of Object.keys(sfData)) {
987
+ if (sfData[k] === undefined)
988
+ delete sfData[k];
989
+ }
990
+ try {
991
+ if (lead.sf_id) {
992
+ await sfPatch(`/sobjects/Lead/${lead.sf_id}`, sfData);
993
+ store.logSfSync({ localTable: 'leads', localId: leadId, sfObjectType: 'Lead', sfId: String(lead.sf_id), syncDirection: 'push' });
994
+ return textResult(`Updated Salesforce Lead ${lead.sf_id} for ${lead.name} <${lead.email}>`);
995
+ }
996
+ else {
997
+ const result = await sfPost('/sobjects/Lead', sfData);
998
+ const sfId = result.id;
999
+ store.upsertLead({ agentSlug: String(lead.agent_slug), email: String(lead.email), name: String(lead.name), sfId });
1000
+ store.logSfSync({ localTable: 'leads', localId: leadId, sfObjectType: 'Lead', sfId, syncDirection: 'push' });
1001
+ return textResult(`Created Salesforce Lead ${sfId} for ${lead.name} <${lead.email}>`);
1002
+ }
1003
+ }
1004
+ catch (err) {
1005
+ store.logSfSync({ localTable: 'leads', localId: leadId, sfObjectType: 'Lead', sfId: String(lead.sf_id ?? ''), syncDirection: 'push', syncStatus: 'error', errorMessage: String(err) });
1006
+ return textResult(`Error pushing lead to Salesforce: ${err}`);
1007
+ }
1008
+ });
1009
+ // ── sf_lead_pull ─────────────────────────────────────────────────────────
1010
+ server.tool('sf_lead_pull', 'Pull a Salesforce Lead or Contact into the local lead database by Salesforce ID or email address.', {
1011
+ sfId: z.string().optional().describe('Salesforce Lead/Contact ID'),
1012
+ email: z.string().optional().describe('Email address to search in Salesforce'),
1013
+ }, async ({ sfId, email }) => {
1014
+ if (!sfConfigured())
1015
+ return textResult('Salesforce not configured — set SF_INSTANCE_URL, SF_CLIENT_ID, SF_CLIENT_SECRET, SF_USERNAME, SF_PASSWORD in .env');
1016
+ if (!sfId && !email)
1017
+ return textResult('Provide either sfId or email');
1018
+ try {
1019
+ let record = null;
1020
+ let objectType = 'Lead';
1021
+ if (sfId) {
1022
+ // Try Lead first, then Contact
1023
+ try {
1024
+ record = await sfGet(`/sobjects/Lead/${sfId}`);
1025
+ }
1026
+ catch {
1027
+ record = await sfGet(`/sobjects/Contact/${sfId}`);
1028
+ objectType = 'Contact';
1029
+ }
1030
+ }
1031
+ else if (email) {
1032
+ const soql = `SELECT Id, FirstName, LastName, Email, Company, Title, Status, LeadSource FROM Lead WHERE Email = '${email.replace(/'/g, "\\'")}' LIMIT 1`;
1033
+ const result = await sfQuery(soql);
1034
+ if (result.records?.length > 0) {
1035
+ record = result.records[0];
1036
+ }
1037
+ else {
1038
+ const contactSoql = `SELECT Id, FirstName, LastName, Email, Account.Name, Title FROM Contact WHERE Email = '${email.replace(/'/g, "\\'")}' LIMIT 1`;
1039
+ const contactResult = await sfQuery(contactSoql);
1040
+ if (contactResult.records?.length > 0) {
1041
+ record = contactResult.records[0];
1042
+ objectType = 'Contact';
1043
+ }
1044
+ }
1045
+ }
1046
+ if (!record)
1047
+ return textResult(`No Salesforce Lead or Contact found for ${sfId || email}`);
1048
+ const store = await getStore();
1049
+ const agentSlug = ACTIVE_AGENT_SLUG ?? 'clementine';
1050
+ const name = joinName(record.FirstName, record.LastName);
1051
+ const company = objectType === 'Contact'
1052
+ ? record.Account?.Name ?? ''
1053
+ : record.Company ?? '';
1054
+ const localStatus = SF_TO_LOCAL_STATUS[String(record.Status ?? '')] || 'new';
1055
+ const upsertResult = store.upsertLead({
1056
+ agentSlug,
1057
+ email: String(record.Email ?? email ?? ''),
1058
+ name,
1059
+ company,
1060
+ title: record.Title,
1061
+ status: localStatus,
1062
+ source: record.LeadSource,
1063
+ sfId: String(record.Id),
1064
+ });
1065
+ store.logSfSync({
1066
+ localTable: 'leads', localId: upsertResult.id,
1067
+ sfObjectType: objectType, sfId: String(record.Id), syncDirection: 'pull',
1068
+ });
1069
+ return textResult(`${upsertResult.created ? 'Created' : 'Updated'} local lead from Salesforce ${objectType} ${record.Id}:\n` +
1070
+ ` Name: ${name}\n Email: ${record.Email}\n Company: ${company}\n Status: ${localStatus}`);
1071
+ }
1072
+ catch (err) {
1073
+ return textResult(`Error pulling from Salesforce: ${err}`);
1074
+ }
1075
+ });
1076
+ // ── sf_contact_search ────────────────────────────────────────────────────
1077
+ server.tool('sf_contact_search', 'Search Salesforce Leads and/or Contacts by name, email, or company.', {
1078
+ query: z.string().describe('Search keyword (name, email, or company)'),
1079
+ objectType: z.enum(['Lead', 'Contact', 'Both']).optional().default('Both').describe('Which SF object type to search'),
1080
+ limit: z.number().optional().default(10).describe('Max results to return'),
1081
+ }, async ({ query, objectType, limit }) => {
1082
+ if (!sfConfigured())
1083
+ return textResult('Salesforce not configured — set SF_INSTANCE_URL, SF_CLIENT_ID, SF_CLIENT_SECRET, SF_USERNAME, SF_PASSWORD in .env');
1084
+ try {
1085
+ const safeQuery = query.replace(/'/g, "\\'");
1086
+ const results = [];
1087
+ const maxResults = Math.min(limit, 50);
1088
+ if (objectType === 'Lead' || objectType === 'Both') {
1089
+ const soql = `SELECT Id, FirstName, LastName, Email, Company, Title, Status, LeadSource FROM Lead WHERE Name LIKE '%${safeQuery}%' OR Email LIKE '%${safeQuery}%' OR Company LIKE '%${safeQuery}%' LIMIT ${maxResults}`;
1090
+ const data = await sfQuery(soql);
1091
+ for (const r of data.records ?? []) {
1092
+ results.push(`[Lead] ${joinName(r.FirstName, r.LastName)} <${r.Email ?? 'no email'}> | ${r.Company ?? ''} | ${r.Title ?? ''} | Status: ${r.Status ?? ''} | ID: ${r.Id}`);
1093
+ }
1094
+ }
1095
+ if (objectType === 'Contact' || objectType === 'Both') {
1096
+ const soql = `SELECT Id, FirstName, LastName, Email, Account.Name, Title FROM Contact WHERE Name LIKE '%${safeQuery}%' OR Email LIKE '%${safeQuery}%' OR Account.Name LIKE '%${safeQuery}%' LIMIT ${maxResults}`;
1097
+ const data = await sfQuery(soql);
1098
+ for (const r of data.records ?? []) {
1099
+ const acct = r.Account?.Name ?? '';
1100
+ results.push(`[Contact] ${joinName(r.FirstName, r.LastName)} <${r.Email ?? 'no email'}> | ${acct} | ${r.Title ?? ''} | ID: ${r.Id}`);
1101
+ }
1102
+ }
1103
+ if (results.length === 0)
1104
+ return textResult(`No Salesforce records found matching "${query}"`);
1105
+ return textResult(`${EXTERNAL_CONTENT_TAG}\n\nSalesforce search results for "${query}" (${results.length} found):\n\n${results.join('\n')}`);
1106
+ }
1107
+ catch (err) {
1108
+ return textResult(`Error searching Salesforce: ${err}`);
1109
+ }
1110
+ });
1111
+ // ── sf_opportunity_create ────────────────────────────────────────────────
1112
+ server.tool('sf_opportunity_create', 'Create a new Opportunity in Salesforce, optionally linked to an Account or Contact.', {
1113
+ name: z.string().describe('Opportunity name'),
1114
+ stageName: z.string().describe('Sales stage (e.g., "Prospecting", "Qualification", "Closed Won")'),
1115
+ closeDate: z.string().describe('Expected close date (YYYY-MM-DD)'),
1116
+ amount: z.number().optional().describe('Deal amount in dollars'),
1117
+ accountId: z.string().optional().describe('Salesforce Account ID to link'),
1118
+ contactId: z.string().optional().describe('Salesforce Contact ID to add as Contact Role'),
1119
+ }, async ({ name, stageName, closeDate, amount, accountId, contactId }) => {
1120
+ if (!sfConfigured())
1121
+ return textResult('Salesforce not configured — set SF_INSTANCE_URL, SF_CLIENT_ID, SF_CLIENT_SECRET, SF_USERNAME, SF_PASSWORD in .env');
1122
+ try {
1123
+ const oppData = { Name: name, StageName: stageName, CloseDate: closeDate };
1124
+ if (amount !== undefined)
1125
+ oppData.Amount = amount;
1126
+ if (accountId)
1127
+ oppData.AccountId = accountId;
1128
+ const result = await sfPost('/sobjects/Opportunity', oppData);
1129
+ const oppId = result.id;
1130
+ let contactRoleMsg = '';
1131
+ // Link contact role if provided
1132
+ if (contactId) {
1133
+ try {
1134
+ await sfPost('/sobjects/OpportunityContactRole', {
1135
+ OpportunityId: oppId,
1136
+ ContactId: contactId,
1137
+ Role: 'Decision Maker',
1138
+ });
1139
+ contactRoleMsg = `\nLinked Contact ${contactId} as Decision Maker`;
1140
+ }
1141
+ catch (err) {
1142
+ contactRoleMsg = `\nWarning: Could not link Contact Role: ${err}`;
1143
+ }
1144
+ }
1145
+ return textResult(`Created Opportunity ${oppId}: "${name}" (${stageName}, close: ${closeDate}${amount ? `, $${amount}` : ''})${contactRoleMsg}`);
1146
+ }
1147
+ catch (err) {
1148
+ return textResult(`Error creating Opportunity: ${err}`);
1149
+ }
1150
+ });
1151
+ // ── sf_opportunity_update ────────────────────────────────────────────────
1152
+ server.tool('sf_opportunity_update', 'Update an existing Salesforce Opportunity (stage, amount, close date, etc.).', {
1153
+ sfId: z.string().describe('Salesforce Opportunity ID'),
1154
+ stageName: z.string().optional().describe('New sales stage'),
1155
+ amount: z.number().optional().describe('Updated deal amount'),
1156
+ closeDate: z.string().optional().describe('Updated close date (YYYY-MM-DD)'),
1157
+ description: z.string().optional().describe('Opportunity description/notes'),
1158
+ }, async ({ sfId, stageName, amount, closeDate, description }) => {
1159
+ if (!sfConfigured())
1160
+ return textResult('Salesforce not configured — set SF_INSTANCE_URL, SF_CLIENT_ID, SF_CLIENT_SECRET, SF_USERNAME, SF_PASSWORD in .env');
1161
+ try {
1162
+ const updates = {};
1163
+ if (stageName)
1164
+ updates.StageName = stageName;
1165
+ if (amount !== undefined)
1166
+ updates.Amount = amount;
1167
+ if (closeDate)
1168
+ updates.CloseDate = closeDate;
1169
+ if (description)
1170
+ updates.Description = description;
1171
+ if (Object.keys(updates).length === 0)
1172
+ return textResult('No fields to update');
1173
+ await sfPatch(`/sobjects/Opportunity/${sfId}`, updates);
1174
+ const fields = Object.entries(updates).map(([k, v]) => `${k}: ${v}`).join(', ');
1175
+ return textResult(`Updated Opportunity ${sfId}: ${fields}`);
1176
+ }
1177
+ catch (err) {
1178
+ return textResult(`Error updating Opportunity: ${err}`);
1179
+ }
1180
+ });
1181
+ // ── sf_activity_log ──────────────────────────────────────────────────────
1182
+ server.tool('sf_activity_log', 'Log an activity (Task) to Salesforce linked to a Lead or Contact. Use this to record calls, emails, meetings, etc.', {
1183
+ sfWhoId: z.string().describe('Salesforce Lead or Contact ID to link the activity to'),
1184
+ subject: z.string().describe('Activity subject line'),
1185
+ description: z.string().optional().describe('Activity description/notes'),
1186
+ type: z.enum(['Call', 'Email', 'Meeting', 'Other']).optional().default('Other').describe('Activity type'),
1187
+ status: z.enum(['Completed', 'Not Started', 'In Progress']).optional().default('Completed').describe('Task status'),
1188
+ activityDate: z.string().optional().describe('Activity date (YYYY-MM-DD, defaults to today)'),
1189
+ }, async ({ sfWhoId, subject, description, type, status, activityDate }) => {
1190
+ if (!sfConfigured())
1191
+ return textResult('Salesforce not configured — set SF_INSTANCE_URL, SF_CLIENT_ID, SF_CLIENT_SECRET, SF_USERNAME, SF_PASSWORD in .env');
1192
+ try {
1193
+ const taskData = {
1194
+ WhoId: sfWhoId,
1195
+ Subject: subject,
1196
+ Status: status,
1197
+ Type: type,
1198
+ ActivityDate: activityDate || new Date().toISOString().slice(0, 10),
1199
+ };
1200
+ if (description)
1201
+ taskData.Description = description;
1202
+ const result = await sfPost('/sobjects/Task', taskData);
1203
+ return textResult(`Logged ${type} activity to Salesforce (Task ID: ${result.id}): "${subject}" for ${sfWhoId}`);
1204
+ }
1205
+ catch (err) {
1206
+ return textResult(`Error logging activity to Salesforce: ${err}`);
1207
+ }
1208
+ });
1209
+ // ── sf_sync ──────────────────────────────────────────────────────────────
1210
+ server.tool('sf_sync', 'Run a bidirectional sync between local leads and Salesforce. Pushes unsynced/modified local leads to SF and pulls recently modified SF leads into the local database.', {
1211
+ direction: z.enum(['push', 'pull', 'both']).optional().default('both').describe('Sync direction'),
1212
+ agentSlug: z.string().optional().describe('Only sync leads for this agent'),
1213
+ dryRun: z.boolean().optional().default(false).describe('Preview sync without making changes'),
1214
+ }, async ({ direction, agentSlug, dryRun }) => {
1215
+ if (!sfConfigured())
1216
+ return textResult('Salesforce not configured — set SF_INSTANCE_URL, SF_CLIENT_ID, SF_CLIENT_SECRET, SF_USERNAME, SF_PASSWORD in .env');
1217
+ const store = await getStore();
1218
+ const slug = agentSlug ?? ACTIVE_AGENT_SLUG ?? undefined;
1219
+ const summary = { pushed: 0, pulled: 0, errors: 0, details: [] };
1220
+ try {
1221
+ // ── Push phase ──
1222
+ if (direction === 'push' || direction === 'both') {
1223
+ // Get unsynced leads
1224
+ const unsynced = store.getUnsyncedLeads(slug);
1225
+ // Get recently modified leads that have sfId (need re-push)
1226
+ const lastSync = store.getSfSyncHistory({ limit: 1 });
1227
+ const since = lastSync.length > 0 ? String(lastSync[0].synced_at) : '1970-01-01T00:00:00Z';
1228
+ const modified = store.getLeadsModifiedSince(since, slug)
1229
+ .filter((l) => l.sf_id);
1230
+ const toPush = [...unsynced, ...modified].slice(0, 200); // Batch limit
1231
+ for (const lead of toPush) {
1232
+ const leadId = Number(lead.id);
1233
+ const { FirstName, LastName } = splitName(String(lead.name ?? ''));
1234
+ const sfData = {
1235
+ FirstName, LastName,
1236
+ Email: lead.email,
1237
+ Company: lead.company || '[Unknown]',
1238
+ Title: lead.title || undefined,
1239
+ Status: LOCAL_TO_SF_STATUS[String(lead.status ?? 'new')] || 'Open - Not Contacted',
1240
+ LeadSource: lead.source || undefined,
1241
+ };
1242
+ for (const k of Object.keys(sfData)) {
1243
+ if (sfData[k] === undefined)
1244
+ delete sfData[k];
1245
+ }
1246
+ if (dryRun) {
1247
+ summary.pushed++;
1248
+ summary.details.push(`[DRY RUN] Would push ${lead.name} <${lead.email}> (${lead.sf_id ? 'update' : 'create'})`);
1249
+ continue;
1250
+ }
1251
+ try {
1252
+ if (lead.sf_id) {
1253
+ await sfPatch(`/sobjects/Lead/${lead.sf_id}`, sfData);
1254
+ store.logSfSync({ localTable: 'leads', localId: leadId, sfObjectType: 'Lead', sfId: String(lead.sf_id), syncDirection: 'push' });
1255
+ }
1256
+ else {
1257
+ const result = await sfPost('/sobjects/Lead', sfData);
1258
+ store.upsertLead({ agentSlug: String(lead.agent_slug), email: String(lead.email), name: String(lead.name), sfId: result.id });
1259
+ store.logSfSync({ localTable: 'leads', localId: leadId, sfObjectType: 'Lead', sfId: result.id, syncDirection: 'push' });
1260
+ }
1261
+ summary.pushed++;
1262
+ summary.details.push(`Pushed ${lead.name} <${lead.email}>`);
1263
+ }
1264
+ catch (err) {
1265
+ summary.errors++;
1266
+ summary.details.push(`Error pushing ${lead.email}: ${err}`);
1267
+ store.logSfSync({ localTable: 'leads', localId: leadId, sfObjectType: 'Lead', sfId: String(lead.sf_id ?? ''), syncDirection: 'push', syncStatus: 'error', errorMessage: String(err) });
1268
+ }
1269
+ }
1270
+ }
1271
+ // ── Pull phase ──
1272
+ if (direction === 'pull' || direction === 'both') {
1273
+ const lastSync = store.getSfSyncHistory({ limit: 1 });
1274
+ const since = lastSync.length > 0 ? String(lastSync[0].synced_at) : '1970-01-01T00:00:00Z';
1275
+ const sinceFormatted = since.replace('T', 'T').replace(' ', 'T');
1276
+ const soql = `SELECT Id, FirstName, LastName, Email, Company, Title, Status, LeadSource, SystemModstamp FROM Lead WHERE SystemModstamp > ${sinceFormatted} AND Email != null ORDER BY SystemModstamp ASC LIMIT 200`;
1277
+ try {
1278
+ const data = await sfQuery(soql);
1279
+ const pullSlug = slug ?? 'clementine';
1280
+ for (const record of data.records ?? []) {
1281
+ const name = joinName(record.FirstName, record.LastName);
1282
+ const localStatus = SF_TO_LOCAL_STATUS[String(record.Status ?? '')] || 'new';
1283
+ if (dryRun) {
1284
+ summary.pulled++;
1285
+ summary.details.push(`[DRY RUN] Would pull ${name} <${record.Email}> (SF ID: ${record.Id})`);
1286
+ continue;
1287
+ }
1288
+ try {
1289
+ const upsertResult = store.upsertLead({
1290
+ agentSlug: pullSlug,
1291
+ email: String(record.Email),
1292
+ name,
1293
+ company: record.Company ?? '',
1294
+ title: record.Title,
1295
+ status: localStatus,
1296
+ source: record.LeadSource,
1297
+ sfId: String(record.Id),
1298
+ });
1299
+ store.logSfSync({
1300
+ localTable: 'leads', localId: upsertResult.id,
1301
+ sfObjectType: 'Lead', sfId: String(record.Id), syncDirection: 'pull',
1302
+ });
1303
+ summary.pulled++;
1304
+ summary.details.push(`Pulled ${name} <${record.Email}>`);
1305
+ }
1306
+ catch (err) {
1307
+ summary.errors++;
1308
+ summary.details.push(`Error pulling ${record.Email}: ${err}`);
1309
+ }
1310
+ }
1311
+ }
1312
+ catch (err) {
1313
+ summary.errors++;
1314
+ summary.details.push(`Error querying Salesforce: ${err}`);
1315
+ }
1316
+ }
1317
+ const prefix = dryRun ? '[DRY RUN] ' : '';
1318
+ return textResult(`${prefix}Salesforce sync complete:\n` +
1319
+ ` Pushed: ${summary.pushed}\n Pulled: ${summary.pulled}\n Errors: ${summary.errors}\n\n` +
1320
+ (summary.details.length > 0 ? `Details:\n${summary.details.map(d => ` • ${d}`).join('\n')}` : 'No records to sync.'));
1321
+ }
1322
+ catch (err) {
1323
+ return textResult(`Salesforce sync failed: ${err}`);
1324
+ }
1325
+ });
1326
+ }
1327
+ //# sourceMappingURL=external-tools.js.map