@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.
- package/README.md +548 -0
- package/access/auth.js +289 -0
- package/access/oauth.js +1059 -0
- package/access/resource-metadata.js +167 -0
- package/access/tiers.js +422 -0
- package/analytics.js +634 -0
- package/api-bridge.js +499 -0
- package/governance/money.js +141 -0
- package/output-formatter.js +589 -0
- package/package.json +68 -0
- package/resources.js +246 -0
- package/security.js +690 -0
- package/server.js +2139 -0
- package/server.json +55 -0
- package/skills/backlog-triage/SKILL.md +115 -0
- package/skills/board-review/SKILL.md +96 -0
- package/skills/incident-to-card/SKILL.md +126 -0
- package/skills/log-outreach.md +62 -0
- package/skills/ship-the-card/SKILL.md +155 -0
- package/tool-annotations.js +269 -0
- package/tools/billing.js +149 -0
- package/tools/email-tools.js +652 -0
- package/tools/enterprise-tools.js +651 -0
- package/tools/free-search.js +160 -0
- package/tools/memory.js +440 -0
- package/tools/morning-brief.js +551 -0
- package/tools/paper-tools.js +563 -0
- package/tools/scheduling.js +322 -0
- package/tools/work-board-tools.js +758 -0
- package/toolsets/communications.js +276 -0
- package/toolsets/crm.js +495 -0
- package/toolsets/extensibility.js +1131 -0
- package/toolsets/infrastructure.js +757 -0
- package/toolsets/intelligence.js +232 -0
- package/toolsets/knowledge.js +154 -0
- package/toolsets/matrix.js +217 -0
- package/toolsets/outreach.js +432 -0
- package/toolsets/prospecting.js +314 -0
- package/toolsets/revenue/always-loaded.js +341 -0
- package/toolsets/revenue/sloan-tools.js +81 -0
- 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 };
|