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/CHANGELOG.md +97 -0
- package/dist/calls.d.ts +13 -0
- package/dist/calls.js +14 -3
- package/dist/cli.js +85 -5
- package/dist/connector.js +34 -0
- package/dist/connectors/hubspot.js +182 -24
- package/dist/connectors/salesforce.js +152 -15
- package/dist/draft.js +27 -5
- package/dist/enrich.d.ts +6 -0
- package/dist/enrich.js +47 -1
- package/dist/health.d.ts +12 -0
- package/dist/health.js +29 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/init.d.ts +47 -0
- package/dist/init.js +140 -0
- package/dist/judge.d.ts +36 -3
- package/dist/judge.js +46 -10
- package/dist/signals.d.ts +4 -0
- package/dist/signals.js +1 -0
- package/dist/types.d.ts +23 -0
- package/docs/api.md +16 -1
- package/docs/recipes.md +158 -0
- package/llms.txt +25 -0
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +16 -1
- package/src/calls.ts +24 -3
- package/src/cli.ts +96 -5
- package/src/connector.ts +37 -0
- package/src/connectors/hubspot.ts +180 -23
- package/src/connectors/salesforce.ts +152 -14
- package/src/draft.ts +26 -5
- package/src/enrich.ts +51 -1
- package/src/health.ts +47 -2
- package/src/index.ts +10 -0
- package/src/init.ts +163 -0
- package/src/judge.ts +85 -11
- package/src/signals.ts +5 -0
- package/src/types.ts +23 -0
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
|
-
*
|
|
337
|
-
*
|
|
338
|
-
* fit
|
|
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
|
-
|
|
358
|
+
function findAccountAndBestContact(
|
|
341
359
|
accountDomain: string,
|
|
342
360
|
snapshot: CanonicalGtmSnapshot,
|
|
343
|
-
): {
|
|
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
|
-
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
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
|