fullstackgtm 0.12.0 → 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/dist/index.d.ts CHANGED
@@ -15,6 +15,7 @@ export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
15
15
  export { auditReportToHtml, auditReportToMarkdown, type ReportOptions } from "./report.ts";
16
16
  export { HUBSPOT_DEFAULT_FIELD_MAPPINGS, SALESFORCE_DEFAULT_FIELD_MAPPINGS, mappedField, mappedFields, normalizeFieldMappings, readMappedValue, type CrmObjectType, type FieldMappings, } from "./mappings.ts";
17
17
  export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, duplicateOpenDealRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, requiresHumanInput, staleDealRule, } from "./rules.ts";
18
+ export { extractCallInsights, normalizeTranscript, parseCall, parseTranscript, suggestCallDeal, summarizeInsights, type CallDealSuggestion, type CallInsightType, type ExtractedCallInsight, type ParsedCall, type ParsedTranscriptSegment, } from "./calls.ts";
18
19
  export { sampleSnapshot } from "./sampleData.ts";
19
20
  export { suggestValues, type SuggestionConfidence, type ValueSuggestion } from "./suggest.ts";
20
21
  export type { ApprovalStatus, AuditFinding, AuditFindingSeverity, CanonicalAccount, CanonicalActivity, CanonicalContact, CanonicalDeal, CanonicalGtmSnapshot, CanonicalUser, CrmProvider, GtmAuditRule, GtmConnector, GtmEvidence, GtmEvidenceSourceSystem, GtmObjectType, GtmPolicy, GtmRuleContext, GtmRuleResult, GtmSnapshotIndex, PatchOperation, PatchOperationResult, PatchOperationType, PatchPlan, PatchPlanRun, PatchPlanRunStatus, PatchVerification, PipelineFinding, PipelineFindingStatus, PipelineFindingType, ProviderIdentity, RiskLevel, SourceFreshness, } from "./types.ts";
package/dist/index.js CHANGED
@@ -15,5 +15,6 @@ export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.js";
15
15
  export { auditReportToHtml, auditReportToMarkdown } from "./report.js";
16
16
  export { HUBSPOT_DEFAULT_FIELD_MAPPINGS, SALESFORCE_DEFAULT_FIELD_MAPPINGS, mappedField, mappedFields, normalizeFieldMappings, readMappedValue, } from "./mappings.js";
17
17
  export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, duplicateOpenDealRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, requiresHumanInput, staleDealRule, } from "./rules.js";
18
+ export { extractCallInsights, normalizeTranscript, parseCall, parseTranscript, suggestCallDeal, summarizeInsights, } from "./calls.js";
18
19
  export { sampleSnapshot } from "./sampleData.js";
19
20
  export { suggestValues } from "./suggest.js";
package/dist/mcp.js CHANGED
@@ -45,6 +45,7 @@ import { generateDemoSnapshot } from "./demo.js";
45
45
  import { formatPatchPlanRun, patchPlanToMarkdown } from "./format.js";
46
46
  import { builtinAuditRules } from "./rules.js";
47
47
  import { sampleSnapshot } from "./sampleData.js";
48
+ import { parseCall } from "./calls.js";
48
49
  import { suggestValues } from "./suggest.js";
49
50
  function content(value) {
50
51
  return {
@@ -156,6 +157,25 @@ export async function startMcpServer() {
156
157
  const snapshot = await readSnapshot(provider, inputPath);
157
158
  return content({ suggestions: suggestValues(plan, snapshot) });
158
159
  });
160
+ server.registerTool("fullstackgtm_call_parse", {
161
+ title: "Parse Call Transcript",
162
+ description: "Deterministically parse a call transcript (Speaker:/[Speaker]: lines or Granola " +
163
+ "utterance JSON) into canonical segments, keyword-derived insights (next steps, " +
164
+ "objections, pricing, risks, competitor mentions...), and GtmEvidence records. " +
165
+ "Read-only and LLM-free; pair with fullstackgtm_audit/apply for governed writes.",
166
+ inputSchema: {
167
+ transcript: z.string().optional(),
168
+ transcriptPath: z.string().optional(),
169
+ title: z.string().optional(),
170
+ source: z.enum(["gong", "chorus", "fathom", "manual", "csv", "unknown"]).optional(),
171
+ },
172
+ }, async ({ transcript, transcriptPath, title, source }) => {
173
+ const raw = transcript ??
174
+ (transcriptPath ? readFileSync(resolve(process.cwd(), transcriptPath), "utf8") : null);
175
+ if (!raw)
176
+ throw new Error("Provide transcript (text) or transcriptPath (file).");
177
+ return content(parseCall(raw, { title, sourceSystem: source }));
178
+ });
159
179
  server.registerTool("fullstackgtm_rules", {
160
180
  title: "List Audit Rules",
161
181
  description: "List the built-in deterministic audit rules with ids and descriptions.",
package/llms.txt CHANGED
@@ -18,6 +18,13 @@ at/above `--fail-on`.
18
18
  - [CRM-health lifecycle](https://github.com/fullstackgtm/core/blob/main/docs/crm-health-lifecycle.md): the Prevent → Detect → Remediate → Verify/Attribute model; no-new-dupes design
19
19
  - [CHANGELOG](https://github.com/fullstackgtm/core/blob/main/CHANGELOG.md): release history
20
20
 
21
+ ## Key invariants (calls)
22
+
23
+ `fullstackgtm call parse` (and MCP fullstackgtm_call_parse) is deterministic
24
+ and LLM-free; `call link` suggests the deal with confidence + reason;
25
+ `call plan` proposes governed next-step writes through the standard
26
+ approve/apply lifecycle.
27
+
21
28
  ## Key invariants
22
29
 
23
30
  - Reads are safe by default; nothing is written without explicit `--approve`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Full Stack GTM",
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
+ }