fullstackgtm 0.11.1 → 0.13.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/CHANGELOG.md +56 -0
- package/README.md +20 -1
- package/dist/calls.d.ts +72 -0
- package/dist/calls.js +345 -0
- package/dist/cli.js +179 -0
- package/dist/connectors/hubspot.js +70 -0
- package/dist/connectors/salesforce.js +9 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp.js +20 -0
- package/dist/rules.js +21 -18
- package/dist/suggest.js +77 -0
- package/dist/types.d.ts +1 -1
- package/docs/api.md +1 -1
- package/docs/crm-health-lifecycle.md +1 -1
- package/llms.txt +7 -0
- package/package.json +1 -1
- package/src/calls.ts +434 -0
- package/src/cli.ts +193 -0
- package/src/connectors/hubspot.ts +71 -0
- package/src/connectors/salesforce.ts +10 -0
- package/src/index.ts +13 -0
- package/src/mcp.ts +26 -0
- package/src/rules.ts +24 -18
- package/src/suggest.ts +88 -0
- package/src/types.ts +5 -1
package/src/calls.ts
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
export type CallInsightType =
|
|
2
|
+
| "pain_point"
|
|
3
|
+
| "objection"
|
|
4
|
+
| "competitor_mention"
|
|
5
|
+
| "next_step"
|
|
6
|
+
| "feature_request"
|
|
7
|
+
| "pricing"
|
|
8
|
+
| "decision_criteria"
|
|
9
|
+
| "risk"
|
|
10
|
+
| "coaching_moment";
|
|
11
|
+
|
|
12
|
+
export type ParsedTranscriptSegment = {
|
|
13
|
+
index: number;
|
|
14
|
+
speaker?: string;
|
|
15
|
+
speakerRole: "rep" | "customer" | "unknown";
|
|
16
|
+
text: string;
|
|
17
|
+
startChar: number;
|
|
18
|
+
endChar: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type ExtractedCallInsight = {
|
|
22
|
+
type: CallInsightType;
|
|
23
|
+
title: string;
|
|
24
|
+
text: string;
|
|
25
|
+
evidence: string;
|
|
26
|
+
speaker?: string;
|
|
27
|
+
confidence: number;
|
|
28
|
+
importance: number;
|
|
29
|
+
segmentIndex?: number;
|
|
30
|
+
startChar?: number;
|
|
31
|
+
endChar?: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const DEFAULT_REP_SPEAKERS = [
|
|
35
|
+
"rep",
|
|
36
|
+
"sales",
|
|
37
|
+
"seller",
|
|
38
|
+
"ae",
|
|
39
|
+
"sdr",
|
|
40
|
+
"bdr",
|
|
41
|
+
"account executive",
|
|
42
|
+
"host",
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const CUSTOMER_HINTS = [
|
|
46
|
+
"customer",
|
|
47
|
+
"prospect",
|
|
48
|
+
"buyer",
|
|
49
|
+
"client",
|
|
50
|
+
"champion",
|
|
51
|
+
"participant",
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const insightPatterns: Array<{
|
|
55
|
+
type: CallInsightType;
|
|
56
|
+
title: string;
|
|
57
|
+
confidence: number;
|
|
58
|
+
importance: number;
|
|
59
|
+
patterns: RegExp[];
|
|
60
|
+
}> = [
|
|
61
|
+
{
|
|
62
|
+
type: "pain_point",
|
|
63
|
+
title: "Customer pain point",
|
|
64
|
+
confidence: 0.78,
|
|
65
|
+
importance: 4,
|
|
66
|
+
patterns: [
|
|
67
|
+
/\b(struggling|struggle|pain|problem|challenge|frustrat(?:ed|ing)|manual|messy|broken|hard to|difficult|too much time|takes too long)\b/i,
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
type: "objection",
|
|
72
|
+
title: "Sales objection",
|
|
73
|
+
confidence: 0.76,
|
|
74
|
+
importance: 4,
|
|
75
|
+
patterns: [
|
|
76
|
+
/\b(concern|worried|not sure|too expensive|budget|price|pricing|timing|procurement|security review|legal review|need to think|another vendor)\b/i,
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
type: "competitor_mention",
|
|
81
|
+
title: "Competitor or alternative mentioned",
|
|
82
|
+
confidence: 0.72,
|
|
83
|
+
importance: 3,
|
|
84
|
+
patterns: [
|
|
85
|
+
/\b(competitor|alternative|evaluating|looked at|using|salesforce|hubspot|gong|chorus|clari|outreach|salesloft|6sense|demandbase|clay|zapier|workato)\b/i,
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
type: "next_step",
|
|
90
|
+
title: "Next step",
|
|
91
|
+
confidence: 0.82,
|
|
92
|
+
importance: 5,
|
|
93
|
+
patterns: [
|
|
94
|
+
/\b(next step|follow up|send over|circle back|schedule|book|meet again|by (monday|tuesday|wednesday|thursday|friday)|action item|I'll send|we'll send)\b/i,
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
type: "feature_request",
|
|
99
|
+
title: "Feature request",
|
|
100
|
+
confidence: 0.74,
|
|
101
|
+
importance: 3,
|
|
102
|
+
patterns: [
|
|
103
|
+
/\b(wish|would love|need it to|feature|capability|does it support|can it|integration|custom field|dashboard|report|export|import)\b/i,
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
type: "pricing",
|
|
108
|
+
title: "Pricing or budget signal",
|
|
109
|
+
confidence: 0.76,
|
|
110
|
+
importance: 4,
|
|
111
|
+
patterns: [
|
|
112
|
+
/\b(price|pricing|budget|cost|discount|contract|renewal|procurement|purchase order|PO|invoice|annual|monthly|ARR|MRR|\$\d+)\b/i,
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
type: "decision_criteria",
|
|
117
|
+
title: "Decision criteria",
|
|
118
|
+
confidence: 0.73,
|
|
119
|
+
importance: 4,
|
|
120
|
+
patterns: [
|
|
121
|
+
/\b(criteria|decision|evaluate|evaluation|success looks like|requirements|must have|need to see|stakeholder|committee|approve|approval)\b/i,
|
|
122
|
+
],
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
type: "risk",
|
|
126
|
+
title: "Deal risk",
|
|
127
|
+
confidence: 0.75,
|
|
128
|
+
importance: 5,
|
|
129
|
+
patterns: [
|
|
130
|
+
/\b(delay|blocked|risk|concern|not a priority|no budget|push|slip|stalled|ghost|unresponsive|champion left|reorg)\b/i,
|
|
131
|
+
],
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
type: "coaching_moment",
|
|
135
|
+
title: "Coaching moment",
|
|
136
|
+
confidence: 0.68,
|
|
137
|
+
importance: 2,
|
|
138
|
+
patterns: [
|
|
139
|
+
/\b(let me tell you|obviously|basically|trust me|to be honest|just checking in|does that make sense\?)\b/i,
|
|
140
|
+
],
|
|
141
|
+
},
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
export function parseTranscript(transcript: string): ParsedTranscriptSegment[] {
|
|
145
|
+
const normalized = transcript.replace(/\r\n/g, "\n").trim();
|
|
146
|
+
if (!normalized) return [];
|
|
147
|
+
|
|
148
|
+
const lines = normalized.split(/\n+/);
|
|
149
|
+
const segments: ParsedTranscriptSegment[] = [];
|
|
150
|
+
let cursor = 0;
|
|
151
|
+
let current: ParsedTranscriptSegment | null = null;
|
|
152
|
+
|
|
153
|
+
for (const rawLine of lines) {
|
|
154
|
+
const line = rawLine.trim();
|
|
155
|
+
if (!line) {
|
|
156
|
+
cursor += rawLine.length + 1;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const startChar = normalized.indexOf(line, cursor);
|
|
161
|
+
const lineStart = startChar >= 0 ? startChar : cursor;
|
|
162
|
+
const lineEnd = lineStart + line.length;
|
|
163
|
+
const match = line.match(/^([^:\[\]]{1,60}|\[[^\]]{1,60}\])\s*:\s*(.+)$/);
|
|
164
|
+
|
|
165
|
+
if (match) {
|
|
166
|
+
const speaker = match[1].replace(/^\[|\]$/g, "").trim();
|
|
167
|
+
const text = match[2].trim();
|
|
168
|
+
current = {
|
|
169
|
+
index: segments.length,
|
|
170
|
+
speaker,
|
|
171
|
+
speakerRole: inferSpeakerRole(speaker),
|
|
172
|
+
text,
|
|
173
|
+
startChar: lineStart + line.indexOf(text),
|
|
174
|
+
endChar: lineEnd,
|
|
175
|
+
};
|
|
176
|
+
segments.push(current);
|
|
177
|
+
} else if (current) {
|
|
178
|
+
current.text = `${current.text}\n${line}`;
|
|
179
|
+
current.endChar = lineEnd;
|
|
180
|
+
} else {
|
|
181
|
+
current = {
|
|
182
|
+
index: segments.length,
|
|
183
|
+
speakerRole: "unknown",
|
|
184
|
+
text: line,
|
|
185
|
+
startChar: lineStart,
|
|
186
|
+
endChar: lineEnd,
|
|
187
|
+
};
|
|
188
|
+
segments.push(current);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
cursor = lineEnd;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return segments;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function extractCallInsights(
|
|
198
|
+
transcript: string,
|
|
199
|
+
segments = parseTranscript(transcript),
|
|
200
|
+
): ExtractedCallInsight[] {
|
|
201
|
+
const seen = new Set<string>();
|
|
202
|
+
const insights: ExtractedCallInsight[] = [];
|
|
203
|
+
|
|
204
|
+
for (const segment of segments) {
|
|
205
|
+
const text = segment.text.trim();
|
|
206
|
+
if (!text || text.length < 12) continue;
|
|
207
|
+
|
|
208
|
+
for (const pattern of insightPatterns) {
|
|
209
|
+
if (!pattern.patterns.some((candidate) => candidate.test(text))) continue;
|
|
210
|
+
const key = `${pattern.type}:${text.slice(0, 180).toLowerCase()}`;
|
|
211
|
+
if (seen.has(key)) continue;
|
|
212
|
+
seen.add(key);
|
|
213
|
+
|
|
214
|
+
insights.push({
|
|
215
|
+
type: pattern.type,
|
|
216
|
+
title: pattern.title,
|
|
217
|
+
text: text.length > 500 ? `${text.slice(0, 497)}...` : text,
|
|
218
|
+
evidence: text,
|
|
219
|
+
speaker: segment.speaker,
|
|
220
|
+
confidence: pattern.confidence,
|
|
221
|
+
importance: pattern.importance,
|
|
222
|
+
segmentIndex: segment.index,
|
|
223
|
+
startChar: segment.startChar,
|
|
224
|
+
endChar: segment.endChar,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return insights.sort((a, b) => b.importance - a.importance || b.confidence - a.confidence);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function summarizeInsights(insights: ExtractedCallInsight[]) {
|
|
233
|
+
const byType: Record<string, number> = {};
|
|
234
|
+
let highImportance = 0;
|
|
235
|
+
for (const insight of insights) {
|
|
236
|
+
byType[insight.type] = (byType[insight.type] ?? 0) + 1;
|
|
237
|
+
if (insight.importance >= 4) highImportance += 1;
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
total: insights.length,
|
|
241
|
+
highImportance,
|
|
242
|
+
byType,
|
|
243
|
+
topTypes: Object.entries(byType)
|
|
244
|
+
.sort((a, b) => b[1] - a[1])
|
|
245
|
+
.slice(0, 5)
|
|
246
|
+
.map(([type, count]) => ({ type, count })),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function inferSpeakerRole(speaker: string): ParsedTranscriptSegment["speakerRole"] {
|
|
251
|
+
const normalized = speaker.toLowerCase();
|
|
252
|
+
if (DEFAULT_REP_SPEAKERS.some((hint) => normalized.includes(hint))) return "rep";
|
|
253
|
+
if (CUSTOMER_HINTS.some((hint) => normalized.includes(hint))) return "customer";
|
|
254
|
+
return "unknown";
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── Canonical call parsing (dialect normalization + evidence) ─────────────
|
|
258
|
+
//
|
|
259
|
+
// Three transcript dialects exist in the wild that this package normalizes:
|
|
260
|
+
// 1. "Speaker: text" lines (Fathom, Gong exports, the app's provider sync)
|
|
261
|
+
// 2. "[Speaker]: text" lines (manually labeled transcripts)
|
|
262
|
+
// 3. Granola raw utterance JSON: [{ text, source: "microphone"|"system", ... }]
|
|
263
|
+
// — no speaker names; microphone = the note-taker ("Me"), system = the
|
|
264
|
+
// other side ("Them").
|
|
265
|
+
|
|
266
|
+
import type { CanonicalGtmSnapshot, GtmEvidence, GtmEvidenceSourceSystem } from "./types.ts";
|
|
267
|
+
|
|
268
|
+
export type ParsedCall = {
|
|
269
|
+
/** Stable hash of the normalized transcript — same input, same id. */
|
|
270
|
+
id: string;
|
|
271
|
+
title?: string;
|
|
272
|
+
sourceSystem: GtmEvidenceSourceSystem;
|
|
273
|
+
segments: ParsedTranscriptSegment[];
|
|
274
|
+
insights: ExtractedCallInsight[];
|
|
275
|
+
evidence: GtmEvidence[];
|
|
276
|
+
summary: ReturnType<typeof summarizeInsights>;
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
type GranolaUtterance = { text?: string; source?: string };
|
|
280
|
+
|
|
281
|
+
/** Detect and normalize a raw transcript payload into "Speaker: text" lines. */
|
|
282
|
+
export function normalizeTranscript(raw: string): string {
|
|
283
|
+
const trimmed = raw.trim();
|
|
284
|
+
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
|
|
285
|
+
try {
|
|
286
|
+
const parsed = JSON.parse(trimmed) as unknown;
|
|
287
|
+
const utterances = Array.isArray(parsed)
|
|
288
|
+
? (parsed as GranolaUtterance[])
|
|
289
|
+
: Array.isArray((parsed as { transcript?: unknown }).transcript)
|
|
290
|
+
? ((parsed as { transcript: GranolaUtterance[] }).transcript)
|
|
291
|
+
: null;
|
|
292
|
+
if (utterances && utterances.every((u) => typeof u === "object" && u !== null && "text" in u)) {
|
|
293
|
+
return utterances
|
|
294
|
+
.filter((u) => typeof u.text === "string" && u.text.trim())
|
|
295
|
+
.map((u) => `${u.source === "microphone" ? "[Me]" : "[Them]"}: ${String(u.text).trim()}`)
|
|
296
|
+
.join("\n");
|
|
297
|
+
}
|
|
298
|
+
} catch {
|
|
299
|
+
// not JSON — fall through and treat as plain text
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return raw;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Parse any supported transcript dialect into a canonical call: segments,
|
|
307
|
+
* deterministic insights, and GtmEvidence records ready for findings and
|
|
308
|
+
* patch plans. Pure and deterministic — same transcript, same ids.
|
|
309
|
+
*/
|
|
310
|
+
export function parseCall(
|
|
311
|
+
raw: string,
|
|
312
|
+
options: { title?: string; sourceSystem?: GtmEvidenceSourceSystem; capturedAt?: string } = {},
|
|
313
|
+
): ParsedCall {
|
|
314
|
+
const normalized = normalizeTranscript(raw);
|
|
315
|
+
const segments = parseTranscript(normalized);
|
|
316
|
+
const insights = extractCallInsights(normalized, segments);
|
|
317
|
+
const sourceSystem = options.sourceSystem ?? "manual";
|
|
318
|
+
const id = `call_${callHash(normalized)}`;
|
|
319
|
+
const evidence: GtmEvidence[] = insights.map((insight, index) => ({
|
|
320
|
+
id: `ev_${callHash(`${id}:${insight.type}:${index}:${insight.text.slice(0, 80)}`)}`,
|
|
321
|
+
sourceSystem,
|
|
322
|
+
sourceObjectType: "call",
|
|
323
|
+
sourceObjectId: id,
|
|
324
|
+
title: insight.title,
|
|
325
|
+
text: insight.evidence,
|
|
326
|
+
capturedAt: options.capturedAt,
|
|
327
|
+
metadata: {
|
|
328
|
+
insightType: insight.type,
|
|
329
|
+
speaker: insight.speaker,
|
|
330
|
+
confidence: insight.confidence,
|
|
331
|
+
importance: insight.importance,
|
|
332
|
+
segmentIndex: insight.segmentIndex,
|
|
333
|
+
},
|
|
334
|
+
}));
|
|
335
|
+
return {
|
|
336
|
+
id,
|
|
337
|
+
title: options.title,
|
|
338
|
+
sourceSystem,
|
|
339
|
+
segments,
|
|
340
|
+
insights,
|
|
341
|
+
evidence,
|
|
342
|
+
summary: summarizeInsights(insights),
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export type CallDealSuggestion = {
|
|
347
|
+
dealId: string | null;
|
|
348
|
+
dealName?: string;
|
|
349
|
+
accountId?: string;
|
|
350
|
+
accountName?: string;
|
|
351
|
+
confidence: "high" | "low" | "none";
|
|
352
|
+
reason: string;
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Suggest which deal a call belongs to: attendee email domains → account
|
|
357
|
+
* (contact emails or account domain) → open deals, most recent activity
|
|
358
|
+
* first. Deterministic; mirrors the heuristic every call pipeline hand-rolls.
|
|
359
|
+
*/
|
|
360
|
+
export function suggestCallDeal(
|
|
361
|
+
snapshot: CanonicalGtmSnapshot,
|
|
362
|
+
options: { attendeeEmails?: string[]; domain?: string },
|
|
363
|
+
): CallDealSuggestion {
|
|
364
|
+
const domains = new Set<string>();
|
|
365
|
+
if (options.domain) domains.add(options.domain.trim().toLowerCase());
|
|
366
|
+
for (const email of options.attendeeEmails ?? []) {
|
|
367
|
+
const at = email.indexOf("@");
|
|
368
|
+
if (at > 0) domains.add(email.slice(at + 1).trim().toLowerCase());
|
|
369
|
+
}
|
|
370
|
+
if (domains.size === 0) {
|
|
371
|
+
return { dealId: null, confidence: "none", reason: "No attendee emails or domain supplied to match on." };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const accountIds = new Set<string>();
|
|
375
|
+
for (const account of snapshot.accounts) {
|
|
376
|
+
const domain = account.domain?.trim().toLowerCase().replace(/^www\./, "");
|
|
377
|
+
if (domain && domains.has(domain)) accountIds.add(account.id);
|
|
378
|
+
}
|
|
379
|
+
for (const contact of snapshot.contacts) {
|
|
380
|
+
const email = contact.email?.trim().toLowerCase();
|
|
381
|
+
const at = email ? email.indexOf("@") : -1;
|
|
382
|
+
if (email && at > 0 && domains.has(email.slice(at + 1)) && contact.accountId) {
|
|
383
|
+
accountIds.add(contact.accountId);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
if (accountIds.size === 0) {
|
|
387
|
+
return {
|
|
388
|
+
dealId: null,
|
|
389
|
+
confidence: "none",
|
|
390
|
+
reason: `No account matches the attendee domain(s) ${[...domains].join(", ")} by account domain or contact email.`,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
const accountsById = new Map(snapshot.accounts.map((a) => [a.id, a]));
|
|
394
|
+
const openDeals = snapshot.deals
|
|
395
|
+
.filter((deal) => deal.accountId && accountIds.has(deal.accountId))
|
|
396
|
+
.filter((deal) => deal.isClosed !== true && deal.isWon !== true)
|
|
397
|
+
.sort((a, b) => Date.parse(b.lastActivityAt ?? "1970") - Date.parse(a.lastActivityAt ?? "1970"));
|
|
398
|
+
if (openDeals.length === 0) {
|
|
399
|
+
const names = [...accountIds].map((id) => accountsById.get(id)?.name ?? id).join(", ");
|
|
400
|
+
return {
|
|
401
|
+
dealId: null,
|
|
402
|
+
confidence: "none",
|
|
403
|
+
reason: `Matched account(s) ${names} but found no open deals on them.`,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
const top = openDeals[0];
|
|
407
|
+
const account = top.accountId ? accountsById.get(top.accountId) : undefined;
|
|
408
|
+
if (openDeals.length === 1) {
|
|
409
|
+
return {
|
|
410
|
+
dealId: top.id,
|
|
411
|
+
dealName: top.name,
|
|
412
|
+
accountId: top.accountId,
|
|
413
|
+
accountName: account?.name,
|
|
414
|
+
confidence: "high",
|
|
415
|
+
reason: `"${top.name}" is the only open deal on matched account "${account?.name ?? top.accountId}".`,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
return {
|
|
419
|
+
dealId: top.id,
|
|
420
|
+
dealName: top.name,
|
|
421
|
+
accountId: top.accountId,
|
|
422
|
+
accountName: account?.name,
|
|
423
|
+
confidence: "low",
|
|
424
|
+
reason: `${openDeals.length} open deals on matched account(s); "${top.name}" has the most recent activity. Confirm before writing.`,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function callHash(value: string) {
|
|
429
|
+
let hash = 0;
|
|
430
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
431
|
+
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
|
|
432
|
+
}
|
|
433
|
+
return hash.toString(36);
|
|
434
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -38,6 +38,7 @@ import { createFilePlanStore } from "./planStore.ts";
|
|
|
38
38
|
import { auditReportToHtml, auditReportToMarkdown, type ReportOptions } from "./report.ts";
|
|
39
39
|
import { builtinAuditRules } from "./rules.ts";
|
|
40
40
|
import { sampleSnapshot } from "./sampleData.ts";
|
|
41
|
+
import { parseCall, suggestCallDeal, type ExtractedCallInsight, type ParsedCall } from "./calls.ts";
|
|
41
42
|
import { suggestValues, type ValueSuggestion } from "./suggest.ts";
|
|
42
43
|
import type { FieldMappings } from "./mappings.ts";
|
|
43
44
|
import type {
|
|
@@ -69,6 +70,11 @@ Usage:
|
|
|
69
70
|
fullstackgtm report [source options] [audit options] [report options]
|
|
70
71
|
fullstackgtm diff --before <a.json> --after <b.json> [--json] [--fail-on-new-findings]
|
|
71
72
|
fullstackgtm merge --input <a.json> --input <b.json> [...] --out <merged.json> [--json]
|
|
73
|
+
fullstackgtm call parse --transcript <file> [--title t] [--source fathom|granola|...] [--json|--ndjson] [--out <path>]
|
|
74
|
+
fullstackgtm call link --attendees <a@x.com,...> | --domain <x.com> [source options] [--json]
|
|
75
|
+
fullstackgtm call plan --transcript <file>|--call <parsed.json> --deal <id> [source options] [--save|--json]
|
|
76
|
+
calls become evidence: parse dialects (Speaker:/[Me]/Granola JSON),
|
|
77
|
+
link to the right deal, and propose governed next-step writes
|
|
72
78
|
fullstackgtm suggest --plan-id <id> | --plan <path> [source options] [--json] [--out <path>]
|
|
73
79
|
derive values for requires_human_* placeholders
|
|
74
80
|
from snapshot evidence, with confidence + reasons
|
|
@@ -465,6 +471,189 @@ function parseValueOverrides(args: string[]) {
|
|
|
465
471
|
return valueOverrides;
|
|
466
472
|
}
|
|
467
473
|
|
|
474
|
+
async function callCommand(args: string[]) {
|
|
475
|
+
const [subcommand, ...rest] = args;
|
|
476
|
+
|
|
477
|
+
const loadParsedCall = (): ParsedCall => {
|
|
478
|
+
const callPath = option(rest, "--call");
|
|
479
|
+
if (callPath) {
|
|
480
|
+
return JSON.parse(readFileSync(resolve(process.cwd(), callPath), "utf8")) as ParsedCall;
|
|
481
|
+
}
|
|
482
|
+
const transcriptPath = option(rest, "--transcript");
|
|
483
|
+
if (!transcriptPath) throw new Error(`call ${subcommand} requires --transcript <file> or --call <parsed.json>`);
|
|
484
|
+
const raw = readFileSync(resolve(process.cwd(), transcriptPath), "utf8");
|
|
485
|
+
const source = option(rest, "--source") as ParsedCall["sourceSystem"] | undefined;
|
|
486
|
+
return parseCall(raw, {
|
|
487
|
+
title: option(rest, "--title") ?? undefined,
|
|
488
|
+
sourceSystem: source,
|
|
489
|
+
capturedAt: new Date().toISOString(),
|
|
490
|
+
});
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
if (subcommand === "parse") {
|
|
494
|
+
const parsed = loadParsedCall();
|
|
495
|
+
const outPath = option(rest, "--out");
|
|
496
|
+
if (outPath) writeFileSync(resolve(process.cwd(), outPath), `${JSON.stringify(parsed, null, 2)}\n`);
|
|
497
|
+
if (rest.includes("--ndjson")) {
|
|
498
|
+
// One flat row per insight — warehouse-friendly (e.g. Snowflake COPY).
|
|
499
|
+
for (const insight of parsed.insights) {
|
|
500
|
+
console.log(
|
|
501
|
+
JSON.stringify({
|
|
502
|
+
call_id: parsed.id,
|
|
503
|
+
call_title: parsed.title ?? null,
|
|
504
|
+
source_system: parsed.sourceSystem,
|
|
505
|
+
type: insight.type,
|
|
506
|
+
title: insight.title,
|
|
507
|
+
text: insight.text,
|
|
508
|
+
evidence: insight.evidence,
|
|
509
|
+
speaker: insight.speaker ?? null,
|
|
510
|
+
confidence: insight.confidence,
|
|
511
|
+
importance: insight.importance,
|
|
512
|
+
}),
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
if (rest.includes("--json") || outPath) {
|
|
518
|
+
if (!outPath) console.log(JSON.stringify(parsed, null, 2));
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
console.log(`Call ${parsed.id}${parsed.title ? ` — ${parsed.title}` : ""} (${parsed.sourceSystem})`);
|
|
522
|
+
console.log(`${parsed.segments.length} segments · ${parsed.insights.length} insights (${parsed.summary.highImportance} high-importance)\n`);
|
|
523
|
+
for (const insight of parsed.insights) {
|
|
524
|
+
console.log(`[${insight.type}] (importance ${insight.importance}) ${insight.text}`);
|
|
525
|
+
}
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (subcommand === "link") {
|
|
530
|
+
const attendees = option(rest, "--attendees");
|
|
531
|
+
const domain = option(rest, "--domain");
|
|
532
|
+
if (!attendees && !domain) throw new Error("call link requires --attendees <emails,comma-separated> and/or --domain <example.com>");
|
|
533
|
+
const snapshot = await readSnapshot(rest);
|
|
534
|
+
const suggestion = suggestCallDeal(snapshot, {
|
|
535
|
+
attendeeEmails: attendees?.split(",").map((e) => e.trim()).filter(Boolean),
|
|
536
|
+
domain: domain ?? undefined,
|
|
537
|
+
});
|
|
538
|
+
if (rest.includes("--json")) {
|
|
539
|
+
console.log(JSON.stringify(suggestion, null, 2));
|
|
540
|
+
} else {
|
|
541
|
+
const marker = suggestion.confidence === "high" ? "✓" : suggestion.confidence === "low" ? "~" : "✗";
|
|
542
|
+
console.log(`${marker} [${suggestion.confidence}] ${suggestion.dealId ?? "no match"}${suggestion.dealName ? ` — ${suggestion.dealName}` : ""}`);
|
|
543
|
+
console.log(` ${suggestion.reason}`);
|
|
544
|
+
}
|
|
545
|
+
if (suggestion.confidence === "none") process.exitCode = 1;
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (subcommand === "plan") {
|
|
550
|
+
const dealId = option(rest, "--deal");
|
|
551
|
+
if (!dealId) throw new Error("call plan requires --deal <dealId> (use `call link` to find it)");
|
|
552
|
+
const parsed = loadParsedCall();
|
|
553
|
+
const snapshot = await readSnapshot(rest);
|
|
554
|
+
const deal = snapshot.deals.find((row) => row.id === dealId);
|
|
555
|
+
if (!deal) throw new Error(`Deal ${dealId} is not in the snapshot — check the id or the snapshot source.`);
|
|
556
|
+
|
|
557
|
+
const nextSteps = parsed.insights.filter((insight) => insight.type === "next_step");
|
|
558
|
+
if (nextSteps.length === 0) {
|
|
559
|
+
console.log("No next-step insights in this call — nothing to plan. (Other insight types are evidence, not writes.)");
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
const [top, ...others] = nextSteps;
|
|
563
|
+
const proposed = top.text.trim().slice(0, 255);
|
|
564
|
+
const current = deal.nextStep?.trim() ?? "";
|
|
565
|
+
const plan = buildCallPlan(parsed, deal, proposed, current, others.slice(0, 3));
|
|
566
|
+
|
|
567
|
+
if (rest.includes("--save")) {
|
|
568
|
+
await createFilePlanStore().save(plan);
|
|
569
|
+
console.log(
|
|
570
|
+
`Saved plan ${plan.id}. Review with \`fullstackgtm plans show ${plan.id}\`, approve with \`fullstackgtm plans approve ${plan.id} --operations <ids|all>\`, then \`fullstackgtm apply --plan-id ${plan.id} --provider <name>\`.`,
|
|
571
|
+
);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
console.log(rest.includes("--json") ? JSON.stringify(plan, null, 2) : patchPlanToMarkdown(plan));
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
throw new Error(`call supports: parse, link, plan (got ${subcommand ?? "nothing"})`);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function buildCallPlan(
|
|
582
|
+
parsed: ParsedCall,
|
|
583
|
+
deal: { id: string; name: string; nextStep?: string },
|
|
584
|
+
proposed: string,
|
|
585
|
+
current: string,
|
|
586
|
+
extraNextSteps: ExtractedCallInsight[],
|
|
587
|
+
): PatchPlan {
|
|
588
|
+
const findings: PatchPlan["findings"] = [];
|
|
589
|
+
const operations: PatchPlan["operations"] = [];
|
|
590
|
+
const nextStepEvidence = parsed.evidence.filter(
|
|
591
|
+
(item) => (item.metadata as { insightType?: string } | undefined)?.insightType === "next_step",
|
|
592
|
+
);
|
|
593
|
+
const evidenceIds = nextStepEvidence.map((item) => item.id);
|
|
594
|
+
|
|
595
|
+
if (current.toLowerCase() !== proposed.toLowerCase()) {
|
|
596
|
+
findings.push({
|
|
597
|
+
id: `finding_${parsed.id.replace(/^call_/, "")}_${deal.id}`,
|
|
598
|
+
objectType: "deal",
|
|
599
|
+
objectId: deal.id,
|
|
600
|
+
ruleId: "call-next-step-not-reflected-in-crm",
|
|
601
|
+
type: "call_next_step_not_reflected_in_crm",
|
|
602
|
+
title: "Call agreed a next step the CRM does not reflect",
|
|
603
|
+
severity: "warning",
|
|
604
|
+
summary: current
|
|
605
|
+
? `The call produced "${proposed}" but ${deal.name}'s next step still reads "${current}".`
|
|
606
|
+
: `The call produced "${proposed}" but ${deal.name} has no next step set.`,
|
|
607
|
+
recommendation: "Review the evidence and approve the next-step update.",
|
|
608
|
+
evidenceIds,
|
|
609
|
+
currentCrmValue: current || null,
|
|
610
|
+
proposedValue: proposed,
|
|
611
|
+
});
|
|
612
|
+
operations.push({
|
|
613
|
+
id: `op_${parsed.id.replace(/^call_/, "")}_next`,
|
|
614
|
+
objectType: "deal",
|
|
615
|
+
objectId: deal.id,
|
|
616
|
+
operation: "set_field",
|
|
617
|
+
field: "nextStep",
|
|
618
|
+
beforeValue: current || null,
|
|
619
|
+
afterValue: proposed,
|
|
620
|
+
reason: `Call evidence: ${nextStepEvidence[0]?.text.slice(0, 200) ?? proposed}`,
|
|
621
|
+
sourceRuleOrPolicy: "call_intelligence.next_step",
|
|
622
|
+
riskLevel: "high",
|
|
623
|
+
approvalRequired: true,
|
|
624
|
+
rollback: "Restore the previous deal next step (the before value) if the update is wrong.",
|
|
625
|
+
evidenceIds,
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
for (const [index, extra] of extraNextSteps.entries()) {
|
|
629
|
+
operations.push({
|
|
630
|
+
id: `op_${parsed.id.replace(/^call_/, "")}_task${index}`,
|
|
631
|
+
objectType: "deal",
|
|
632
|
+
objectId: deal.id,
|
|
633
|
+
operation: "create_task",
|
|
634
|
+
field: "follow_up_task",
|
|
635
|
+
beforeValue: null,
|
|
636
|
+
afterValue: extra.text.trim().slice(0, 255),
|
|
637
|
+
reason: `Additional commitment from the call: ${extra.evidence.slice(0, 160)}`,
|
|
638
|
+
sourceRuleOrPolicy: "call_intelligence.follow_up",
|
|
639
|
+
riskLevel: "low",
|
|
640
|
+
approvalRequired: true,
|
|
641
|
+
rollback: "Close or delete the created task.",
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
return {
|
|
645
|
+
id: `patch_plan_${parsed.id.replace(/^call_/, "")}${deal.id.slice(-4)}`,
|
|
646
|
+
title: `Call evidence plan${parsed.title ? ` — ${parsed.title}` : ""} → ${deal.name}`,
|
|
647
|
+
createdAt: new Date().toISOString(),
|
|
648
|
+
status: "needs_approval",
|
|
649
|
+
dryRun: true,
|
|
650
|
+
summary: `${findings.length} finding(s) and ${operations.length} proposed operation(s) from call ${parsed.id}.`,
|
|
651
|
+
findings,
|
|
652
|
+
evidence: parsed.evidence,
|
|
653
|
+
operations,
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
|
|
468
657
|
async function suggest(args: string[]) {
|
|
469
658
|
const planId = option(args, "--plan-id");
|
|
470
659
|
const planPath = option(args, "--plan");
|
|
@@ -1271,6 +1460,10 @@ export async function runCli(argv: string[]) {
|
|
|
1271
1460
|
await suggest(args);
|
|
1272
1461
|
return;
|
|
1273
1462
|
}
|
|
1463
|
+
if (command === "call") {
|
|
1464
|
+
await callCommand(args);
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1274
1467
|
if (command === "profiles") {
|
|
1275
1468
|
profilesCommand(args);
|
|
1276
1469
|
return;
|