@adrata/adrata-mcp 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 (41) hide show
  1. package/README.md +548 -0
  2. package/access/auth.js +289 -0
  3. package/access/oauth.js +1059 -0
  4. package/access/resource-metadata.js +167 -0
  5. package/access/tiers.js +422 -0
  6. package/analytics.js +634 -0
  7. package/api-bridge.js +499 -0
  8. package/governance/money.js +141 -0
  9. package/output-formatter.js +589 -0
  10. package/package.json +68 -0
  11. package/resources.js +246 -0
  12. package/security.js +690 -0
  13. package/server.js +2139 -0
  14. package/server.json +55 -0
  15. package/skills/backlog-triage/SKILL.md +115 -0
  16. package/skills/board-review/SKILL.md +96 -0
  17. package/skills/incident-to-card/SKILL.md +126 -0
  18. package/skills/log-outreach.md +62 -0
  19. package/skills/ship-the-card/SKILL.md +155 -0
  20. package/tool-annotations.js +269 -0
  21. package/tools/billing.js +149 -0
  22. package/tools/email-tools.js +652 -0
  23. package/tools/enterprise-tools.js +651 -0
  24. package/tools/free-search.js +160 -0
  25. package/tools/memory.js +440 -0
  26. package/tools/morning-brief.js +551 -0
  27. package/tools/paper-tools.js +563 -0
  28. package/tools/scheduling.js +322 -0
  29. package/tools/work-board-tools.js +758 -0
  30. package/toolsets/communications.js +276 -0
  31. package/toolsets/crm.js +495 -0
  32. package/toolsets/extensibility.js +1131 -0
  33. package/toolsets/infrastructure.js +757 -0
  34. package/toolsets/intelligence.js +232 -0
  35. package/toolsets/knowledge.js +154 -0
  36. package/toolsets/matrix.js +217 -0
  37. package/toolsets/outreach.js +432 -0
  38. package/toolsets/prospecting.js +314 -0
  39. package/toolsets/revenue/always-loaded.js +341 -0
  40. package/toolsets/revenue/sloan-tools.js +81 -0
  41. package/transport-http.js +505 -0
@@ -0,0 +1,551 @@
1
+ /**
2
+ * Morning Brief — the hero experience that creates daily habit.
3
+ *
4
+ * On the first tool call of each day, Adrata delivers a proactive morning
5
+ * brief: top priorities, overnight signals, pipeline health, calendar prep,
6
+ * and a single decisive coaching directive.
7
+ *
8
+ * Tier behavior:
9
+ * - Free tier: teaser brief (1 signal + upgrade prompt)
10
+ * - Pro+ tier: full brief (parallel API calls, role-adaptive)
11
+ *
12
+ * Caching:
13
+ * - Cached for the calendar day so it is not repeated in subsequent sessions
14
+ * - Cache stored locally in ~/.adrata/morning-brief-cache.json
15
+ * - last_morning_brief_date tracked in memory
16
+ */
17
+
18
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
19
+ import { homedir } from 'node:os';
20
+ import { join } from 'node:path';
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Cache layer — one brief per calendar day
24
+ // ---------------------------------------------------------------------------
25
+
26
+ const ADRATA_DIR = join(homedir(), '.adrata');
27
+ const CACHE_FILE = join(ADRATA_DIR, 'morning-brief-cache.json');
28
+
29
+ function ensureDir() {
30
+ if (!existsSync(ADRATA_DIR)) {
31
+ mkdirSync(ADRATA_DIR, { recursive: true });
32
+ }
33
+ }
34
+
35
+ function todayKey() {
36
+ return new Date().toISOString().slice(0, 10); // YYYY-MM-DD
37
+ }
38
+
39
+ function readCache() {
40
+ ensureDir();
41
+ if (!existsSync(CACHE_FILE)) return {};
42
+ try {
43
+ return JSON.parse(readFileSync(CACHE_FILE, 'utf-8'));
44
+ } catch {
45
+ return {};
46
+ }
47
+ }
48
+
49
+ function writeCache(cache) {
50
+ ensureDir();
51
+ writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2));
52
+ }
53
+
54
+ function getCachedBrief() {
55
+ const cache = readCache();
56
+ const today = todayKey();
57
+ if (cache.date === today && cache.brief) {
58
+ return cache.brief;
59
+ }
60
+ return null;
61
+ }
62
+
63
+ function setCachedBrief(brief) {
64
+ writeCache({ date: todayKey(), brief });
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Role detection
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /**
72
+ * Determine user role from profile data. Falls back to 'ae' (Account Exec)
73
+ * which gives a balanced brief.
74
+ */
75
+ function detectRole(profileData) {
76
+ if (!profileData) return 'ae';
77
+ const role = (profileData.user?.role || '').toLowerCase();
78
+ if (role.includes('sdr') || role.includes('bdr') || role.includes('development')) return 'sdr';
79
+ if (role.includes('vp') || role.includes('director') || role.includes('head') || role.includes('manager')) return 'vp';
80
+ if (role.includes('ae') || role.includes('account exec') || role.includes('executive')) return 'ae';
81
+ return 'ae';
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Greeting based on time of day
86
+ // ---------------------------------------------------------------------------
87
+
88
+ function greeting() {
89
+ const hour = new Date().getHours();
90
+ if (hour < 12) return 'Good morning';
91
+ if (hour < 17) return 'Good afternoon';
92
+ return 'Good evening';
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Parallel data fetching (Pro tier)
97
+ // ---------------------------------------------------------------------------
98
+
99
+ /**
100
+ * Fetch all brief data in parallel. Each call is wrapped so a single failure
101
+ * does not break the entire brief.
102
+ *
103
+ * These hit the REAL platform endpoints. The previous `/api/v1/mcp/speedrun`,
104
+ * `/api/v1/mcp/intent-signals`, `/api/v1/mcp/emails`,
105
+ * `/api/v1/mcp/pipeline-metrics`, and `/api/v1/mcp/calendar` paths never
106
+ * existed (all 404'd), so every Pro brief silently rendered the "quiet night"
107
+ * fallback.
108
+ */
109
+ async function fetchBriefData(apiFn) {
110
+ const safe = async (fn) => {
111
+ try {
112
+ return await fn();
113
+ } catch {
114
+ return null;
115
+ }
116
+ };
117
+
118
+ const [serverBrief, speedrun, signals, emails, pipeline, calendar, profile] = await Promise.all([
119
+ // Canonical server-generated morning brief (null until generated).
120
+ safe(() => apiFn('GET', '/api/v1/morning-brief')),
121
+ // Ranked speedrun/today's list.
122
+ safe(() => apiFn('GET', '/api/v1/speedrun', { params: { limit: 3 } })),
123
+ // Intent signals.
124
+ safe(() => apiFn('GET', '/api/v1/signals/intent', { params: { limit: 5 } })),
125
+ // Unread inbox messages.
126
+ safe(() => apiFn('GET', '/api/v1/inbox', { params: { unread: true, limit: 5 } })),
127
+ // Pipeline metrics.
128
+ safe(() => apiFn('GET', '/api/v1/metrics/pipeline')),
129
+ // Upcoming calendar events. The server orders by startTime ASC over ALL
130
+ // history, so without a start_date floor this returned the five OLDEST
131
+ // events ever recorded. Pass the full current instant (a bare date would
132
+ // be read as UTC midnight — up to 17h off for a Pacific user), and no
133
+ // end_date: the server applies it to the nullable endTime column, which
134
+ // silently drops all-day/imported events. ASC + limit already bounds the
135
+ // window to the next five events.
136
+ safe(() =>
137
+ apiFn('GET', '/api/v1/events', {
138
+ params: { start_date: new Date().toISOString(), limit: 5 },
139
+ })
140
+ ),
141
+ safe(() => apiFn('GET', '/api/v1/mcp/profile')),
142
+ ]);
143
+
144
+ return { serverBrief, speedrun, signals, emails, pipeline, calendar, profile };
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Markdown formatters
149
+ // ---------------------------------------------------------------------------
150
+
151
+ function formatSignals(signals, role) {
152
+ if (!signals?.data?.length) return null;
153
+
154
+ const items = signals.data.slice(0, role === 'vp' ? 5 : 3);
155
+ const lines = items.map((s, i) => {
156
+ const company = s.company_name || s.company || 'Unknown';
157
+ const signal = s.signal_type || s.type || 'activity';
158
+ const detail = s.description || s.summary || '';
159
+ const score = s.intent_score != null ? ` Intent: ${s.intent_score}.` : '';
160
+ const action = s.recommended_action || s.action || '';
161
+ return `${i + 1}. **${company}** ${detail}${score}\n -> ${action || `Follow up on ${signal} signal.`}`;
162
+ });
163
+
164
+ return `### Overnight Signals\n\n${lines.join('\n\n')}`;
165
+ }
166
+
167
+ function formatSpeedrun(speedrun, role) {
168
+ if (!speedrun?.data?.length) return null;
169
+
170
+ if (role === 'vp') {
171
+ // VP sees a summary, not individual accounts
172
+ return null;
173
+ }
174
+
175
+ const items = speedrun.data.slice(0, 3);
176
+ const rows = items.map((s, i) => {
177
+ const name = s.company_name || s.name || 'Account';
178
+ const reason = s.reason || s.why_today || s.summary || 'Priority account';
179
+ return `| ${i + 1} | ${name} | ${reason} |`;
180
+ });
181
+
182
+ // "Speedrun" is retired product naming; the surface is "today's list". The
183
+ // /api/v1/speedrun route keeps its name for compatibility.
184
+ return `### Today's List (Top 3)\n\n| # | Account | Why Today |\n|---|---------|-----------|\n${rows.join('\n')}`;
185
+ }
186
+
187
+ function formatPipeline(pipeline) {
188
+ if (!pipeline?.data) return null;
189
+
190
+ const d = pipeline.data;
191
+ const parts = [];
192
+ if (d.weighted_value != null) parts.push(`$${formatCurrency(d.weighted_value)} weighted`);
193
+ if (d.closing_this_month != null) parts.push(`${d.closing_this_month} closing this month`);
194
+ if (d.at_risk != null) parts.push(`${d.at_risk} at risk`);
195
+ if (d.deals_advanced != null) parts.push(`${d.deals_advanced} advanced`);
196
+ if (d.deals_stalled != null) parts.push(`${d.deals_stalled} stalled`);
197
+
198
+ if (parts.length === 0) return null;
199
+
200
+ return `### Pipeline Health\n${parts.join(' | ')}`;
201
+ }
202
+
203
+ function formatCalendar(calendar) {
204
+ if (!calendar?.data?.length) return null;
205
+
206
+ const meetings = calendar.data.slice(0, 5);
207
+ const rows = meetings.map((m) => {
208
+ // Render in the seller's local time; the API returns UTC ISO strings.
209
+ const rawTime = m.startTime || m.start_time || m.time || '';
210
+ const parsed = rawTime ? new Date(rawTime) : null;
211
+ const time =
212
+ parsed && !Number.isNaN(parsed.getTime())
213
+ ? parsed.toLocaleString(undefined, {
214
+ weekday: 'short',
215
+ month: 'short',
216
+ day: 'numeric',
217
+ hour: 'numeric',
218
+ minute: '2-digit',
219
+ })
220
+ : rawTime;
221
+ const title = m.title || m.subject || 'Meeting';
222
+ const attendees = m.attendee_count || m.attendees?.length || '?';
223
+ const prep = m.prep_note || '';
224
+ return `- **${time}** ${title} (${attendees} attendees)${prep ? `\n -> ${prep}` : ''}`;
225
+ });
226
+
227
+ return `### Calendar\n\n${rows.join('\n')}`;
228
+ }
229
+
230
+ function formatEmails(emails) {
231
+ if (!emails?.data?.length) return null;
232
+
233
+ const count = emails.data.length;
234
+ const hasMore = emails.meta?.total_count > count;
235
+ const preview = emails.data.slice(0, 3).map((e) => {
236
+ const from = e.from_name || e.from || 'Someone';
237
+ const subject = e.subject || 'No subject';
238
+ return `- **${from}**: ${subject}`;
239
+ });
240
+
241
+ const moreText = hasMore ? `\n\n${emails.meta.total_count - count} more unread.` : '';
242
+ return `### New Replies\n\n${preview.join('\n')}${moreText}`;
243
+ }
244
+
245
+ function formatCurrency(value) {
246
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
247
+ if (value >= 1_000) return `${(value / 1_000).toFixed(0)}K`;
248
+ return String(value);
249
+ }
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // Role-adaptive section ordering
253
+ // ---------------------------------------------------------------------------
254
+
255
+ function buildProBrief(data, role) {
256
+ const { serverBrief, speedrun, signals, emails, pipeline, calendar } = data;
257
+ const sections = [];
258
+
259
+ // Header is appended last (see below) so it can describe what the brief
260
+ // actually contains. It used to be a hardcoded "Three things before your
261
+ // 10am.", which invented a meeting for any user whose calendar was empty and
262
+ // contradicted itself after midday ("Good afternoon ... before your 10am").
263
+ const headerIndex = sections.length;
264
+ sections.push('');
265
+
266
+ // Canonical server-generated brief, when one exists for today.
267
+ const briefBody = serverBrief?.data?.content || serverBrief?.data?.summary;
268
+ if (typeof briefBody === 'string' && briefBody.trim()) {
269
+ sections.push(briefBody.trim());
270
+ }
271
+
272
+ // SDR: prospecting focus — signals first, speedrun, emails
273
+ // AE: deal focus — signals, pipeline, calendar, speedrun
274
+ // VP: pipeline focus — pipeline, signals, calendar
275
+ if (role === 'sdr') {
276
+ const s = formatSignals(signals, role);
277
+ const sr = formatSpeedrun(speedrun, role);
278
+ const e = formatEmails(emails);
279
+ if (s) sections.push(s);
280
+ if (sr) sections.push(sr);
281
+ if (e) sections.push(e);
282
+ const p = formatPipeline(pipeline);
283
+ if (p) sections.push(p);
284
+ } else if (role === 'vp') {
285
+ const p = formatPipeline(pipeline);
286
+ const s = formatSignals(signals, role);
287
+ const c = formatCalendar(calendar);
288
+ if (p) sections.push(p);
289
+ if (s) sections.push(s);
290
+ if (c) sections.push(c);
291
+ } else {
292
+ // AE default
293
+ const s = formatSignals(signals, role);
294
+ const c = formatCalendar(calendar);
295
+ const sr = formatSpeedrun(speedrun, role);
296
+ const p = formatPipeline(pipeline);
297
+ const e = formatEmails(emails);
298
+ if (s) sections.push(s);
299
+ if (c) sections.push(c);
300
+ if (sr) sections.push(sr);
301
+ if (p) sections.push(p);
302
+ if (e) sections.push(e);
303
+ }
304
+
305
+ // If no data came back at all, give a graceful fallback. Say plainly that
306
+ // there is nothing rather than pointing at a "#1 account" that may not exist.
307
+ if (sections.length <= headerIndex + 1) {
308
+ sections[headerIndex] = `## ${greeting()}.\n`;
309
+ sections.push('Nothing new overnight — no signals, meetings, or pipeline changes to report.');
310
+ sections.push('\nRun `/today` for your ranked list. Run `/pipeline` for deal details.');
311
+ return sections.join('\n\n');
312
+ }
313
+
314
+ sections[headerIndex] = `## ${greeting()}.${nextMeetingClause(calendar)}\n`;
315
+ sections.push('\nRun `/today` for your ranked list. Run `/pipeline` for deal details. Run `/meeting-prep` for your next meeting.');
316
+
317
+ return sections.join('\n\n');
318
+ }
319
+
320
+ /**
321
+ * Describe the next upcoming meeting, or say nothing.
322
+ *
323
+ * Returns '' when the calendar is empty or every event is in the past, so the
324
+ * brief never claims a meeting the seller does not have.
325
+ */
326
+ function nextMeetingClause(calendar) {
327
+ const events = calendar?.data;
328
+ if (!Array.isArray(events) || events.length === 0) return '';
329
+
330
+ const now = Date.now();
331
+ const upcoming = events
332
+ .map((m) => {
333
+ const raw = m.startTime || m.start_time || m.time || '';
334
+ const at = raw ? new Date(raw) : null;
335
+ return at && !Number.isNaN(at.getTime()) ? at : null;
336
+ })
337
+ .filter((at) => at && at.getTime() >= now)
338
+ .sort((a, b) => a - b);
339
+
340
+ if (upcoming.length === 0) return '';
341
+
342
+ const time = upcoming[0].toLocaleString(undefined, { hour: 'numeric', minute: '2-digit' });
343
+ return upcoming.length === 1
344
+ ? ` One meeting today — ${time}.`
345
+ : ` ${upcoming.length} meetings today, next at ${time}.`;
346
+ }
347
+
348
+ // ---------------------------------------------------------------------------
349
+ // Free tier teaser
350
+ // ---------------------------------------------------------------------------
351
+
352
+ function buildFreeBrief() {
353
+ const greet = greeting();
354
+ // HONESTY: never fabricate CRM facts. Without a connected workspace we have
355
+ // no signals, accounts, or pipeline to report — say so plainly.
356
+ return `## ${greet}.
357
+
358
+ ### No workspace connected
359
+
360
+ You're not connected to an Adrata workspace, so there is no real data to brief you on — no signals, accounts, or pipeline are available at this tier.
361
+
362
+ ### What the morning brief includes once connected
363
+
364
+ - **Overnight signals** with intent scores and recommended actions
365
+ - **Pipeline health** — weighted value, deals closing, at-risk alerts
366
+ - **Calendar prep** — intelligence on every meeting before you walk in
367
+ - **Today's list** — your ranked priority accounts and why
368
+ - **One Thing** — the single most important action for the day
369
+
370
+ Connect a workspace to see real data: run \`connect_workspace\`, or set \`ADRATA_API_KEY\` / \`ADRATA_OAUTH_TOKEN\`.`;
371
+ }
372
+
373
+ // ---------------------------------------------------------------------------
374
+ // Track last brief date in memory
375
+ // ---------------------------------------------------------------------------
376
+
377
+ async function trackBriefInMemory(apiFn, auth) {
378
+ try {
379
+ if (auth.authenticated) {
380
+ await apiFn('POST', '/api/v1/mcp/memories', {
381
+ body: {
382
+ content: `last_morning_brief_date: ${todayKey()}`,
383
+ tags: ['system', 'morning_brief'],
384
+ memory_type: 'fact',
385
+ },
386
+ }).catch(() => {});
387
+ } else {
388
+ // Local memory tracking via the memories file
389
+ const MEMORIES_FILE = join(ADRATA_DIR, 'memories.json');
390
+ let memories = [];
391
+ if (existsSync(MEMORIES_FILE)) {
392
+ try {
393
+ memories = JSON.parse(readFileSync(MEMORIES_FILE, 'utf-8'));
394
+ } catch {
395
+ memories = [];
396
+ }
397
+ }
398
+
399
+ // Remove old morning_brief tracking entries
400
+ memories = memories.filter(
401
+ (m) => !(m.tags && m.tags.includes('morning_brief') && m.tags.includes('system'))
402
+ );
403
+
404
+ memories.push({
405
+ id: `brief-${todayKey()}`,
406
+ content: `last_morning_brief_date: ${todayKey()}`,
407
+ memory_type: 'fact',
408
+ tags: ['system', 'morning_brief'],
409
+ confidence: 1.0,
410
+ is_latest: true,
411
+ forgotten_at: null,
412
+ created_at: new Date().toISOString(),
413
+ updated_at: new Date().toISOString(),
414
+ });
415
+
416
+ writeFileSync(MEMORIES_FILE, JSON.stringify(memories, null, 2));
417
+ }
418
+ } catch {
419
+ // Non-critical — do not fail the brief
420
+ }
421
+ }
422
+
423
+ // ---------------------------------------------------------------------------
424
+ // Main brief generation
425
+ // ---------------------------------------------------------------------------
426
+
427
+ /**
428
+ * Generate the morning brief. Returns { brief, cached } where `cached` is
429
+ * true if this is a repeat call on the same day.
430
+ */
431
+ async function generateMorningBrief(apiFn, auth) {
432
+ // Check cache first
433
+ const cached = getCachedBrief();
434
+ if (cached) {
435
+ return { brief: cached, cached: true };
436
+ }
437
+
438
+ let brief;
439
+
440
+ if (auth.authenticated) {
441
+ // Pro/Enterprise: full brief with parallel data fetch
442
+ const data = await fetchBriefData(apiFn);
443
+ const role = detectRole(data.profile?.data || data.profile);
444
+ brief = buildProBrief(data, role);
445
+ } else {
446
+ // Free tier: teaser
447
+ brief = buildFreeBrief();
448
+ }
449
+
450
+ // Cache for the day
451
+ setCachedBrief(brief);
452
+
453
+ // Track in memory (fire and forget)
454
+ trackBriefInMemory(apiFn, auth).catch(() => {});
455
+
456
+ return { brief, cached: false };
457
+ }
458
+
459
+ // ---------------------------------------------------------------------------
460
+ // MCP tool registration
461
+ // ---------------------------------------------------------------------------
462
+
463
+ /**
464
+ * Register the morning_brief tool on the MCP server.
465
+ *
466
+ * @param {object} server - McpServer instance
467
+ * @param {object} z - Zod instance from MCP SDK
468
+ * @param {Function} apiFn - The api() helper
469
+ * @param {object} auth - AUTH context
470
+ * @param {Function} okFn - The ok() response helper
471
+ */
472
+ export function registerMorningBrief(server, z, apiFn, auth, okFn) {
473
+ server.tool(
474
+ 'morning_brief',
475
+ 'Get your daily morning intelligence brief. Delivers top priorities, overnight signals, pipeline health, calendar prep, and coaching — all in 30 seconds. Automatically adapts to your role (SDR/AE/VP). Cached per day so it runs fast on repeat calls. This is the first thing to check every morning.',
476
+ {
477
+ force_refresh: z
478
+ .boolean()
479
+ .optional()
480
+ .describe('Force regeneration even if already delivered today (default: false)'),
481
+ role_override: z
482
+ .enum(['sdr', 'ae', 'vp'])
483
+ .optional()
484
+ .describe('Override auto-detected role for brief focus (default: auto-detect from profile)'),
485
+ },
486
+ async (args) => {
487
+ const startTime = Date.now();
488
+
489
+ // Handle force refresh
490
+ if (args.force_refresh) {
491
+ writeCache({});
492
+ }
493
+
494
+ try {
495
+ // Check cache (unless force refresh)
496
+ if (!args.force_refresh) {
497
+ const cached = getCachedBrief();
498
+ if (cached) {
499
+ return okFn({
500
+ morning_brief: cached,
501
+ cached: true,
502
+ generated_at: todayKey(),
503
+ note: 'Cached from earlier today. Use force_refresh=true to regenerate.',
504
+ });
505
+ }
506
+ }
507
+
508
+ let brief;
509
+
510
+ if (auth.authenticated) {
511
+ // Pro/Enterprise: full parallel data fetch
512
+ const data = await fetchBriefData(apiFn);
513
+ const role = args.role_override || detectRole(data.profile?.data || data.profile);
514
+ brief = buildProBrief(data, role);
515
+ } else {
516
+ // Free tier: teaser
517
+ brief = buildFreeBrief();
518
+ }
519
+
520
+ // Cache for the day
521
+ setCachedBrief(brief);
522
+
523
+ // Track in memory (fire and forget)
524
+ trackBriefInMemory(apiFn, auth).catch(() => {});
525
+
526
+ const latencyMs = Date.now() - startTime;
527
+
528
+ return okFn({
529
+ morning_brief: brief,
530
+ cached: false,
531
+ generated_at: todayKey(),
532
+ latency_ms: latencyMs,
533
+ tier: auth.tier,
534
+ });
535
+ } catch (err) {
536
+ // Graceful degradation: return a minimal brief rather than an error
537
+ const fallback = `## ${greeting()}.\n\nBrief generation encountered an issue. Here's what you can do:\n\n- Run \`/today\` for your ranked priority list\n- Run \`/pipeline\` for deal health\n- Run \`/morning\` to retry the brief\n\nError: ${err.message || 'Unknown error'}`;
538
+
539
+ return okFn({
540
+ morning_brief: fallback,
541
+ cached: false,
542
+ error: true,
543
+ generated_at: todayKey(),
544
+ });
545
+ }
546
+ }
547
+ );
548
+ }
549
+
550
+ // Export for testing
551
+ export { generateMorningBrief, buildProBrief, buildFreeBrief, detectRole, getCachedBrief, setCachedBrief };