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.
@@ -542,6 +542,75 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
542
542
  };
543
543
  }
544
544
 
545
+ /**
546
+ * Merge a duplicate group into the approved survivor via HubSpot's v3
547
+ * merge API (supported for contacts, companies, deals, and tickets).
548
+ * Merges are pairwise and IRREVERSIBLE; the survivor's values win on
549
+ * conflict and each loser is archived by HubSpot. A loser that is already
550
+ * gone (404 — e.g. a replayed plan) is treated as already merged.
551
+ */
552
+ async function mergeRecords(operation: PatchOperation): Promise<PatchOperationResult> {
553
+ const objectPath = OBJECT_PATHS[operation.objectType];
554
+ if (!objectPath || operation.objectType === "user" || operation.objectType === "activity") {
555
+ return {
556
+ operationId: operation.id,
557
+ status: "skipped",
558
+ detail: "merge_records is supported for accounts, contacts, and deals.",
559
+ };
560
+ }
561
+ const survivorId = String(operation.afterValue ?? "");
562
+ const groupIds = Array.isArray(operation.beforeValue)
563
+ ? operation.beforeValue.map((id) => String(id))
564
+ : [];
565
+ if (!survivorId || groupIds.length < 2) {
566
+ return {
567
+ operationId: operation.id,
568
+ status: "skipped",
569
+ detail: "merge_records needs a survivor id (afterValue) and the duplicate group ids (beforeValue).",
570
+ };
571
+ }
572
+ if (!groupIds.includes(survivorId)) {
573
+ return {
574
+ operationId: operation.id,
575
+ status: "skipped",
576
+ detail: `Survivor ${survivorId} is not in the duplicate group (${groupIds.join(", ")}); refusing to merge into an unrelated record.`,
577
+ };
578
+ }
579
+ const losers = groupIds.filter((id) => id !== survivorId);
580
+ const mergedIds: string[] = [];
581
+ const alreadyGoneIds: string[] = [];
582
+ for (const loser of losers) {
583
+ try {
584
+ await request(`/crm/v3/objects/${objectPath}/merge`, {
585
+ method: "POST",
586
+ body: JSON.stringify({ primaryObjectId: survivorId, objectIdToMerge: loser }),
587
+ });
588
+ mergedIds.push(loser);
589
+ } catch (error) {
590
+ const message = error instanceof Error ? error.message : String(error);
591
+ if (message.includes(" 404")) {
592
+ alreadyGoneIds.push(loser); // replayed plan: loser already merged/archived
593
+ continue;
594
+ }
595
+ return {
596
+ operationId: operation.id,
597
+ status: "failed",
598
+ detail: `Merged ${mergedIds.length} of ${losers.length} into ${survivorId}, then failed on ${loser}: ${message}`,
599
+ providerData: { survivorId, mergedIds, alreadyGoneIds },
600
+ };
601
+ }
602
+ }
603
+ return {
604
+ operationId: operation.id,
605
+ status: mergedIds.length === 0 && alreadyGoneIds.length === losers.length ? "skipped" : "applied",
606
+ detail:
607
+ mergedIds.length === 0 && alreadyGoneIds.length === losers.length
608
+ ? `All ${losers.length} duplicates were already merged into ${survivorId}; nothing to do.`
609
+ : `Merged ${mergedIds.length} duplicate ${objectPath} into ${survivorId}${alreadyGoneIds.length ? ` (${alreadyGoneIds.length} already gone)` : ""}. Irreversible.`,
610
+ providerData: { survivorId, mergedIds, alreadyGoneIds },
611
+ };
612
+ }
613
+
545
614
  async function applyOperation(operation: PatchOperation): Promise<PatchOperationResult> {
546
615
  try {
547
616
  switch (operation.operation) {
@@ -552,6 +621,8 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
552
621
  return await linkRecord(operation);
553
622
  case "create_task":
554
623
  return await createTask(operation);
624
+ case "merge_records":
625
+ return await mergeRecords(operation);
555
626
  case "archive_record":
556
627
  return await archiveRecord(operation);
557
628
  default:
@@ -435,6 +435,16 @@ export function createSalesforceConnector(
435
435
  }
436
436
  case "create_task":
437
437
  return await createTask(operation);
438
+ case "merge_records":
439
+ // Salesforce merge exists only in the SOAP API and Apex (Lead,
440
+ // Contact, Account, Case; max 3 records) — there is no REST merge
441
+ // resource. Surface that honestly instead of half-merging.
442
+ return {
443
+ operationId: operation.id,
444
+ status: "skipped",
445
+ detail:
446
+ "Salesforce merge requires the SOAP API or Apex (Lead/Contact/Account/Case only) — this REST connector cannot merge. Merge in the Salesforce UI, or archive the duplicates explicitly.",
447
+ };
438
448
  case "archive_record":
439
449
  return await archiveRecord(operation);
440
450
  default:
package/src/index.ts CHANGED
@@ -98,6 +98,19 @@ export {
98
98
  requiresHumanInput,
99
99
  staleDealRule,
100
100
  } from "./rules.ts";
101
+ export {
102
+ extractCallInsights,
103
+ normalizeTranscript,
104
+ parseCall,
105
+ parseTranscript,
106
+ suggestCallDeal,
107
+ summarizeInsights,
108
+ type CallDealSuggestion,
109
+ type CallInsightType,
110
+ type ExtractedCallInsight,
111
+ type ParsedCall,
112
+ type ParsedTranscriptSegment,
113
+ } from "./calls.ts";
101
114
  export { sampleSnapshot } from "./sampleData.ts";
102
115
  export { suggestValues, type SuggestionConfidence, type ValueSuggestion } from "./suggest.ts";
103
116
  export type {
package/src/mcp.ts CHANGED
@@ -46,6 +46,7 @@ import type { FieldMappings } from "./mappings.ts";
46
46
  import { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
47
47
  import { builtinAuditRules } from "./rules.ts";
48
48
  import { sampleSnapshot } from "./sampleData.ts";
49
+ import { parseCall } from "./calls.ts";
49
50
  import { suggestValues } from "./suggest.ts";
50
51
  import type { CanonicalGtmSnapshot, GtmConnector, PatchPlan } from "./types.ts";
51
52
 
@@ -188,6 +189,31 @@ export async function startMcpServer() {
188
189
  },
189
190
  );
190
191
 
192
+ server.registerTool(
193
+ "fullstackgtm_call_parse",
194
+ {
195
+ title: "Parse Call Transcript",
196
+ description:
197
+ "Deterministically parse a call transcript (Speaker:/[Speaker]: lines or Granola " +
198
+ "utterance JSON) into canonical segments, keyword-derived insights (next steps, " +
199
+ "objections, pricing, risks, competitor mentions...), and GtmEvidence records. " +
200
+ "Read-only and LLM-free; pair with fullstackgtm_audit/apply for governed writes.",
201
+ inputSchema: {
202
+ transcript: z.string().optional(),
203
+ transcriptPath: z.string().optional(),
204
+ title: z.string().optional(),
205
+ source: z.enum(["gong", "chorus", "fathom", "manual", "csv", "unknown"]).optional(),
206
+ },
207
+ },
208
+ async ({ transcript, transcriptPath, title, source }) => {
209
+ const raw =
210
+ transcript ??
211
+ (transcriptPath ? readFileSync(resolve(process.cwd(), transcriptPath), "utf8") : null);
212
+ if (!raw) throw new Error("Provide transcript (text) or transcriptPath (file).");
213
+ return content(parseCall(raw, { title, sourceSystem: source }));
214
+ },
215
+ );
216
+
191
217
  server.registerTool(
192
218
  "fullstackgtm_rules",
193
219
  {
package/src/rules.ts CHANGED
@@ -350,13 +350,15 @@ export const duplicateAccountDomainRule: GtmAuditRule = {
350
350
  id: patchOperationId("duplicate-account-domain", anchor.id),
351
351
  objectType: "account" as const,
352
352
  objectId: anchor.id,
353
- operation: "create_task" as const,
354
- field: "merge_review_task",
355
- beforeValue: null,
356
- afterValue: `Review ${accounts.length} accounts sharing ${domain} and merge duplicates`,
357
- reason: "Duplicate accounts split pipeline, attribution, and ownership.",
358
- riskLevel: "medium" as const,
353
+ operation: "merge_records" as const,
354
+ field: "merge",
355
+ beforeValue: accounts.map((account) => account.id),
356
+ afterValue: "requires_human_survivor_selection",
357
+ reason: `Duplicate accounts split pipeline, attribution, and ownership. Merge the ${accounts.length} accounts sharing ${domain} into one survivor.`,
358
+ riskLevel: "high" as const,
359
359
  approvalRequired: true,
360
+ rollback:
361
+ "IRREVERSIBLE: provider merges cannot be unmerged. The pre-apply snapshot retains every record's field values; recreate a record manually from it if a merge was wrong.",
360
362
  });
361
363
  }
362
364
  return { findings, operations };
@@ -388,13 +390,15 @@ export const duplicateContactEmailRule: GtmAuditRule = {
388
390
  id: patchOperationId("duplicate-contact-email", anchor.id),
389
391
  objectType: "contact" as const,
390
392
  objectId: anchor.id,
391
- operation: "create_task" as const,
392
- field: "merge_review_task",
393
- beforeValue: null,
394
- afterValue: `Review ${contacts.length} contacts sharing ${email} and merge duplicates`,
395
- reason: "Duplicate contacts fragment engagement history and double-route outreach.",
396
- riskLevel: "low" as const,
393
+ operation: "merge_records" as const,
394
+ field: "merge",
395
+ beforeValue: contacts.map((contact) => contact.id),
396
+ afterValue: "requires_human_survivor_selection",
397
+ reason: `Duplicate contacts fragment engagement history and double-route outreach. Merge the ${contacts.length} contacts sharing ${email} into one survivor.`,
398
+ riskLevel: "high" as const,
397
399
  approvalRequired: true,
400
+ rollback:
401
+ "IRREVERSIBLE: provider merges cannot be unmerged. The pre-apply snapshot retains every record's field values; recreate a record manually from it if a merge was wrong.",
398
402
  });
399
403
  }
400
404
  return { findings, operations };
@@ -437,13 +441,15 @@ export const duplicateOpenDealRule: GtmAuditRule = {
437
441
  id: patchOperationId("duplicate-open-deal", anchor.id),
438
442
  objectType: "deal" as const,
439
443
  objectId: anchor.id,
440
- operation: "create_task" as const,
441
- field: "merge_review_task",
442
- beforeValue: null,
443
- afterValue: `Review ${deals.length} duplicate open deals named "${anchor.name}" — keep one, archive ${deals.length - 1}`,
444
- reason: "Duplicate open deals inflate pipeline and forecast the same revenue more than once.",
445
- riskLevel: "medium" as const,
444
+ operation: "merge_records" as const,
445
+ field: "merge",
446
+ beforeValue: deals.map((deal) => deal.id),
447
+ afterValue: "requires_human_survivor_selection",
448
+ reason: `Duplicate open deals inflate pipeline and forecast the same revenue more than once. Merge the ${deals.length} deals named "${anchor.name}" into one survivor.`,
449
+ riskLevel: "high" as const,
446
450
  approvalRequired: true,
451
+ rollback:
452
+ "IRREVERSIBLE: provider merges cannot be unmerged. The pre-apply snapshot retains every record's field values; recreate a record manually from it if a merge was wrong.",
447
453
  });
448
454
  }
449
455
  return { findings, operations };
package/src/suggest.ts CHANGED
@@ -62,6 +62,11 @@ export function suggestValues(
62
62
  continue;
63
63
  }
64
64
 
65
+ if (placeholder === "requires_human_survivor_selection") {
66
+ suggestions.push(suggestSurvivor(operation, snapshot, dealsById));
67
+ continue;
68
+ }
69
+
65
70
  if (placeholder === "requires_human_owner_selection" && activeUsers.length === 1) {
66
71
  suggestions.push({
67
72
  operationId: operation.id,
@@ -194,6 +199,89 @@ function suggestDealAccount(
194
199
  };
195
200
  }
196
201
 
202
+ /**
203
+ * Survivor selection for merge_records. Ranking is deterministic and
204
+ * evidence-based: most complete record first (count of populated canonical
205
+ * fields), most recent activity as the tiebreaker, group order last.
206
+ * Confidence is capped at "low" by design: merges are irreversible, so a
207
+ * survivor suggestion must never clear the default bulk-approval bar —
208
+ * accepting one requires --min-confidence low or an explicit --value.
209
+ */
210
+ function suggestSurvivor(
211
+ operation: PatchOperation,
212
+ snapshot: CanonicalGtmSnapshot,
213
+ dealsById: Map<string, { name: string }>,
214
+ ): ValueSuggestion {
215
+ const base = {
216
+ operationId: operation.id,
217
+ objectType: operation.objectType,
218
+ objectId: operation.objectId,
219
+ objectName: dealsById.get(operation.objectId)?.name,
220
+ placeholder: "requires_human_survivor_selection",
221
+ };
222
+ const groupIds = Array.isArray(operation.beforeValue)
223
+ ? operation.beforeValue.map((id) => String(id))
224
+ : [];
225
+ if (groupIds.length < 2) {
226
+ return {
227
+ ...base,
228
+ suggestedValue: null,
229
+ confidence: "none",
230
+ reason: "Operation does not carry a duplicate group (expected ids in beforeValue).",
231
+ };
232
+ }
233
+ const collection =
234
+ operation.objectType === "account"
235
+ ? snapshot.accounts
236
+ : operation.objectType === "contact"
237
+ ? snapshot.contacts
238
+ : operation.objectType === "deal"
239
+ ? snapshot.deals
240
+ : [];
241
+ const records = groupIds
242
+ .map((id) => collection.find((row) => row.id === id))
243
+ .filter((row): row is NonNullable<typeof row> => row !== undefined);
244
+ if (records.length !== groupIds.length) {
245
+ return {
246
+ ...base,
247
+ suggestedValue: null,
248
+ confidence: "none",
249
+ reason: "Not every group member is present in the snapshot; re-run the audit before merging.",
250
+ };
251
+ }
252
+ const ranked = [...records].sort((a, b) => {
253
+ const completeness = populatedFields(b) - populatedFields(a);
254
+ if (completeness !== 0) return completeness;
255
+ return activityMs(b) - activityMs(a);
256
+ });
257
+ const winner = ranked[0];
258
+ const runnerUp = ranked[1];
259
+ const name = "name" in winner && typeof winner.name === "string" ? winner.name : winner.id;
260
+ return {
261
+ ...base,
262
+ suggestedValue: winner.id,
263
+ confidence: "low",
264
+ reason:
265
+ `"${name}" (${winner.id}) is the most complete record in the group ` +
266
+ `(${populatedFields(winner)} populated fields vs ${populatedFields(runnerUp)}` +
267
+ `${activityMs(winner) > activityMs(runnerUp) ? ", and more recent activity" : ""}). ` +
268
+ `Merging is IRREVERSIBLE — survivor suggestions never exceed low confidence; ` +
269
+ `approve with --min-confidence low or an explicit --value after review.`,
270
+ };
271
+ }
272
+
273
+ function populatedFields(record: object) {
274
+ return Object.values(record).filter(
275
+ (value) => value !== undefined && value !== null && value !== "",
276
+ ).length;
277
+ }
278
+
279
+ function activityMs(record: object) {
280
+ const value = (record as { lastActivityAt?: string }).lastActivityAt;
281
+ const parsed = value ? Date.parse(value) : NaN;
282
+ return Number.isFinite(parsed) ? parsed : 0;
283
+ }
284
+
197
285
  function normalize(value: string) {
198
286
  return value
199
287
  .toLowerCase()
package/src/types.ts CHANGED
@@ -41,7 +41,11 @@ export type PatchOperationType =
41
41
  | "clear_field"
42
42
  | "link_record"
43
43
  | "archive_record"
44
- | "create_task";
44
+ | "create_task"
45
+ // Merge a duplicate group into a survivor. beforeValue is the group's
46
+ // record ids; afterValue is the survivor id (requires_human_survivor_selection
47
+ // until a human picks). IRREVERSIBLE on every provider that supports it.
48
+ | "merge_records";
45
49
 
46
50
  export type AuditFindingSeverity = "info" | "warning" | "critical";
47
51