fullstackgtm 0.41.0 → 0.43.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/src/judge.ts CHANGED
@@ -35,6 +35,23 @@ export type JudgeDecisionKind = "send" | "nurture" | "skip";
35
35
 
36
36
  export type JudgeDecision = {
37
37
  accountDomain: string;
38
+ /**
39
+ * The CRM account this domain resolves to, when a snapshot was provided.
40
+ * Absent = the account is not in the CRM (a net-new domain) — downstream
41
+ * verbs must acquire it before they can write against it.
42
+ */
43
+ accountId?: string;
44
+ /**
45
+ * The contact at the account to reach, when resolvable from the snapshot —
46
+ * the answer to "who do I message at this hot account". `draft` targets this
47
+ * contact's id; absent contact + present accountId targets the account.
48
+ */
49
+ contact?: ContactRef;
50
+ /**
51
+ * All in-CRM contacts at the account (primary first), capped — so an agent can
52
+ * multi-thread beyond the single primary. `contact` is `contacts[0]`.
53
+ */
54
+ contacts?: ContactRef[];
38
55
  /** 0-100. */
39
56
  score: number;
40
57
  decision: JudgeDecisionKind;
@@ -333,22 +350,75 @@ export function accountRecentlyTouched(
333
350
  }
334
351
 
335
352
  /**
336
- * Find the best-matching contact for an account from the snapshot, for fit
337
- * scoring: the account's contacts, preferring one with a title (a title is what
338
- * fit scores on). Returns undefined when the account/contact isn't in snapshot.
353
+ * Resolve a signal's account domain to the CRM account record and the best
354
+ * contact at it (preferring one with a title). The single domain→account→contact
355
+ * join used by both fit scoring and target surfacing so "who do I message at
356
+ * this account" is computed once and consistently.
339
357
  */
340
- export function bestContactForAccount(
358
+ function findAccountAndBestContact(
341
359
  accountDomain: string,
342
360
  snapshot: CanonicalGtmSnapshot,
343
- ): { jobTitle?: string; jobLevel?: string; jobDepartment?: string; headline?: string } | undefined {
361
+ ): {
362
+ account: CanonicalGtmSnapshot["accounts"][number];
363
+ contact?: CanonicalGtmSnapshot["contacts"][number];
364
+ contacts: CanonicalGtmSnapshot["contacts"];
365
+ } | undefined {
344
366
  const domain = normalizeAccountDomain(accountDomain);
345
367
  if (!domain) return undefined;
346
368
  const account = (snapshot.accounts ?? []).find((a) => normalizeAccountDomain(a.domain ?? "") === domain);
347
369
  if (!account) return undefined;
348
- const contacts = (snapshot.contacts ?? []).filter((c) => c.accountId === account.id);
349
- const withTitle = contacts.find((c) => c.title) ?? contacts[0];
350
- if (!withTitle) return undefined;
351
- return { jobTitle: withTitle.title };
370
+ // Titled contacts first (a title is what fit scores on); the first is primary.
371
+ const contacts = (snapshot.contacts ?? [])
372
+ .filter((c) => c.accountId === account.id)
373
+ .sort((a, b) => (b.title ? 1 : 0) - (a.title ? 1 : 0));
374
+ return { account, contact: contacts[0], contacts };
375
+ }
376
+
377
+ /**
378
+ * Best-matching contact for an account, shaped for fit scoring (the title is
379
+ * what `scoreProspectAgainstIcp` reads). Returns undefined when the
380
+ * account/contact isn't in the snapshot.
381
+ */
382
+ export function bestContactForAccount(
383
+ accountDomain: string,
384
+ snapshot: CanonicalGtmSnapshot,
385
+ ): { jobTitle?: string; jobLevel?: string; jobDepartment?: string; headline?: string } | undefined {
386
+ const found = findAccountAndBestContact(accountDomain, snapshot);
387
+ if (!found?.contact) return undefined;
388
+ return { jobTitle: found.contact.title };
389
+ }
390
+
391
+ /**
392
+ * Resolve the CRM target for an account domain: its `accountId` (when the
393
+ * account exists in the snapshot) and the best `contact` to reach (id + email +
394
+ * title). This is what `draft` writes against — a real record id, never the
395
+ * domain. `{}` when the account is not in the CRM (a net-new domain).
396
+ */
397
+ export type ContactRef = { id: string; email?: string; title?: string };
398
+
399
+ /** Cap on candidate contacts surfaced per account (the rest stay in the CRM). */
400
+ const MAX_CANDIDATE_CONTACTS = 10;
401
+
402
+ const toContactRef = (c: CanonicalGtmSnapshot["contacts"][number]): ContactRef => ({
403
+ id: c.id,
404
+ ...(c.email ? { email: c.email } : {}),
405
+ ...(c.title ? { title: c.title } : {}),
406
+ });
407
+
408
+ export function resolveAccountTarget(
409
+ accountDomain: string,
410
+ snapshot: CanonicalGtmSnapshot,
411
+ ): { accountId?: string; contact?: ContactRef; contacts?: ContactRef[] } {
412
+ const found = findAccountAndBestContact(accountDomain, snapshot);
413
+ if (!found) return {};
414
+ const { account, contact, contacts } = found;
415
+ return {
416
+ accountId: account.id,
417
+ ...(contact ? { contact: toContactRef(contact) } : {}),
418
+ // The candidate contacts at the account, so an agent can multi-thread
419
+ // beyond the single primary. Capped; primary is contacts[0].
420
+ ...(contacts.length ? { contacts: contacts.slice(0, MAX_CANDIDATE_CONTACTS).map(toContactRef) } : {}),
421
+ };
352
422
  }
353
423
 
354
424
  // ---------------------------------------------------------------------------
@@ -559,10 +629,11 @@ export async function judgeSignals(opts: {
559
629
 
560
630
  const decisions: JudgeDecision[] = [];
561
631
  for (const [domain, signals] of byAccount) {
632
+ // Memory + fit stay gated on --with-history (scoring semantics unchanged).
562
633
  const recentlyTouched =
563
634
  opts.withHistory && opts.snapshot ? accountRecentlyTouched(domain, opts.snapshot, now) : false;
564
635
  const bestContact =
565
- opts.icp && opts.snapshot ? bestContactForAccount(domain, opts.snapshot) : undefined;
636
+ opts.withHistory && opts.icp && opts.snapshot ? bestContactForAccount(domain, opts.snapshot) : undefined;
566
637
  const score = scoreAccount({
567
638
  accountDomain: domain,
568
639
  signals,
@@ -578,7 +649,10 @@ export async function judgeSignals(opts: {
578
649
  opts.llm && opts.promptTemplate
579
650
  ? await judgeDecisionLlm(score, opts.promptTemplate, opts.llm)
580
651
  : deterministicDecision(score);
581
- decisions.push(decision);
652
+ // Surface the CRM target (accountId + best contact) whenever a snapshot is
653
+ // present — independent of --with-history. This is what makes a decision
654
+ // actionable: draft writes against contact.id / accountId, never the domain.
655
+ decisions.push(opts.snapshot ? { ...decision, ...resolveAccountTarget(domain, opts.snapshot) } : decision);
582
656
  }
583
657
 
584
658
  return decisions.sort((a, b) => b.score - a.score || a.accountDomain.localeCompare(b.accountDomain));
package/src/signals.ts CHANGED
@@ -65,6 +65,9 @@ export type SignalOutcome = {
65
65
  id: string;
66
66
  accountDomain: string;
67
67
  touchId?: string;
68
+ /** The contact the touch went to (from the judge decision), when known — so an
69
+ * outcome credits the person reached, not just the account domain. */
70
+ contactId?: string;
68
71
  result: SignalOutcomeResult;
69
72
  recordedAt: string;
70
73
  /** Signal ids credited (from the judge decision). */
@@ -668,6 +671,7 @@ export function createFileSignalStore(directory?: string): SignalStore {
668
671
  export function makeOutcome(input: {
669
672
  accountDomain: string;
670
673
  touchId?: string;
674
+ contactId?: string;
671
675
  result: SignalOutcomeResult;
672
676
  creditedSignals?: string[];
673
677
  recordedAt?: string;
@@ -678,6 +682,7 @@ export function makeOutcome(input: {
678
682
  id: outcomeId({ accountDomain, touchId: input.touchId, result: input.result, recordedAt }),
679
683
  accountDomain,
680
684
  ...(input.touchId !== undefined ? { touchId: input.touchId } : {}),
685
+ ...(input.contactId !== undefined ? { contactId: input.contactId } : {}),
681
686
  result: input.result,
682
687
  recordedAt,
683
688
  creditedSignals: input.creditedSignals ?? [],
package/src/types.ts CHANGED
@@ -68,6 +68,13 @@ export type CreateRecordPayload = {
68
68
  source: string;
69
69
  estCostUsd?: number;
70
70
  associateCompanyName?: string;
71
+ /**
72
+ * The company's domain, when known. The connector resolves the account by
73
+ * DOMAIN first (the accurate key), creates it with the domain, and fills the
74
+ * domain on an existing account that lacks one — so the lead's account is a
75
+ * real, signal-watchable record (`signals`/`icp judge` key on account domain).
76
+ */
77
+ associateCompanyDomain?: string;
71
78
  /**
72
79
  * Owner to stamp on the new record (canonical owner id). The connector maps
73
80
  * it to the CRM's owner property (HubSpot `hubspot_owner_id`) at create time
@@ -364,7 +371,12 @@ export type PatchPlan = {
364
371
  status: ApprovalStatus;
365
372
  dryRun: true;
366
373
  summary: string;
374
+ /** The COMPLETE, object-typed findings list — every account/contact/deal
375
+ * finding, each carrying its `objectType`. This is the canonical set. */
367
376
  findings: AuditFinding[];
377
+ /** A deal/sales-PIPELINE projection of `findings` (deal-level + the
378
+ * call-next-step type) for the pipeline-trust queue — intentionally a subset.
379
+ * Account/contact hygiene findings are NOT here; read `findings` for those. */
368
380
  pipelineFindings?: PipelineFinding[];
369
381
  evidence?: GtmEvidence[];
370
382
  operations: PatchOperation[];
@@ -470,6 +482,17 @@ export type GtmConnector = {
470
482
  provider: CrmProvider;
471
483
  fetchSnapshot: () => Promise<CanonicalGtmSnapshot>;
472
484
  applyOperation?: (operation: PatchOperation) => Promise<PatchOperationResult>;
485
+ /**
486
+ * Bulk fast-path for independent `create_record` contact ops: batch the
487
+ * resolve-first read and the create writes instead of one round-trip per
488
+ * record (orders of magnitude fewer API calls for lead acquisition). The
489
+ * caller (`applyPatchPlan`) only routes ops here that are safe to batch —
490
+ * approved, no group, no value override, no company association — and falls
491
+ * back to `applyOperation` for the rest. Implementations MUST preserve
492
+ * resolve-first (never create over an existing match) and return exactly one
493
+ * result per input op. Optional: connectors without it use `applyOperation`.
494
+ */
495
+ applyCreateContactsBatch?: (operations: PatchOperation[]) => Promise<PatchOperationResult[]>;
473
496
  /**
474
497
  * Read the live value of one canonical field, used for compare-and-set:
475
498
  * apply orchestration refuses to write over values that drifted since the